<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Waseem Anjum</title>
    <description>The latest articles on DEV Community by Waseem Anjum (@fabwaseem).</description>
    <link>https://dev.to/fabwaseem</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1255115%2Fc0788b1c-8747-4486-bdbc-c978454ee541.png</url>
      <title>DEV Community: Waseem Anjum</title>
      <link>https://dev.to/fabwaseem</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fabwaseem"/>
    <language>en</language>
    <item>
      <title>Zod vs Valibot vs ArkType for MCP Tool Schemas</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Sat, 07 Nov 2026 06:14:45 +0000</pubDate>
      <link>https://dev.to/fabwaseem/zod-vs-valibot-vs-arktype-for-mcp-tool-schemas-373i</link>
      <guid>https://dev.to/fabwaseem/zod-vs-valibot-vs-arktype-for-mcp-tool-schemas-373i</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/zod-vs-valibot-vs-arktype" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When I built the &lt;a href="https://fabwaseem.com/blog/nestjs-mcp-server" rel="noopener noreferrer"&gt;NestJS MCP server&lt;/a&gt; earlier this week, I used Zod for the tool schemas without thinking about it, because every SDK example does. Then I noticed the v2 TypeScript SDK types &lt;code&gt;inputSchema&lt;/code&gt; as "any Standard Schema that can also emit JSON Schema", and the docstring names Zod 4, Valibot and ArkType. So today I wrote the same tool schema in all three, registered them on one MCP server, and measured what actually changes.&lt;/p&gt;

&lt;p&gt;Short version: all three work. The differences that mattered were in the JSON Schema the model sees, how each one treats a datetime with a &lt;code&gt;+05:00&lt;/code&gt; offset, cold start, bundle size, and one ArkType setting that made the same schema about 18x slower.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I ran
&lt;/h2&gt;

&lt;p&gt;Everything is on one shared 8 vCPU Xeon VM with 16 GB, on November 7, 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 24.22.1&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;zod&lt;/code&gt; 4.6.7, &lt;code&gt;valibot&lt;/code&gt; 1.5.0 with &lt;code&gt;@valibot/to-json-schema&lt;/code&gt; 1.8.0, &lt;code&gt;arktype&lt;/code&gt; 2.2.9&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@modelcontextprotocol/server&lt;/code&gt; and &lt;code&gt;@modelcontextprotocol/client&lt;/code&gt; 2.4.0, connected with &lt;code&gt;InMemoryTransport&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tinybench&lt;/code&gt; 6.2.3 for the parse numbers, &lt;code&gt;esbuild&lt;/code&gt; 0.28.2 for bundle sizes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Load average sat around 0.7 to 1.7 during the runs. It's a shared box, so treat the numbers as relative, not absolute.&lt;/p&gt;

&lt;h2&gt;
  
  
  One tool, three schemas
&lt;/h2&gt;

&lt;p&gt;The tool is a &lt;code&gt;create_task&lt;/code&gt; with a title, optional body, a priority enum with a default, a tag list with a default, an optional due date and an optional assignee email. That's roughly what my real tools look like: a few strings with limits, an enum, an array, and two formats.&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="kd"&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zodTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;body&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;5000&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;priority&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="s2"&gt;low&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;normal&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;high&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;"&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;30&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;10&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;([]),&lt;/span&gt;
  &lt;span class="na"&gt;dueAt&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="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&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;assigneeEmail&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="nf"&gt;optional&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="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;v&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;valibot&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;toStandardJsonSchema&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;@valibot/to-json-schema&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;valibotTaskRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minLength&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="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;picklist&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;low&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;normal&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;high&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;normal&lt;/span&gt;&lt;span class="dl"&gt;"&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;v&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxLength&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="p"&gt;[]),&lt;/span&gt;
  &lt;span class="na"&gt;dueAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isoTimestamp&lt;/span&gt;&lt;span class="p"&gt;())),&lt;/span&gt;
  &lt;span class="na"&gt;assigneeEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;())),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Plain Valibot schemas don't carry ~standard.jsonSchema, so wrap them.&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;valibotTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toStandardJsonSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;valibotTaskRaw&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="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="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;arktype&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;arkTask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;type&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1 &amp;lt;= string &amp;lt;= 120&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;body?&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;string &amp;lt;= 5000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'low' | 'normal' | 'high'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;(string &amp;lt;= 30)[] &amp;lt;= 10&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;default&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dueAt?&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;string.date.iso&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;assigneeEmail?&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;string.email&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;Registering them is the same call for all three:&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;McpServer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;InMemoryTransport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/client&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;server&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;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tasks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;for &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;name&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="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[[&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="nx"&gt;zodTask&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;valibot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;valibotTask&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;arktype&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;arkTask&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="kd"&gt;as const&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`create_task_&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Create a task (&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="na"&gt;inputSchema&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;clientSide&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;serverSide&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;InMemoryTransport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createLinkedPair&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;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;serverSide&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientSide&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTools&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one Valibot gotcha: if you pass &lt;code&gt;valibotTaskRaw&lt;/code&gt; directly, &lt;code&gt;registerTool&lt;/code&gt; accepts it without complaint. The error only shows up later, when a client calls &lt;code&gt;tools/list&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Schema library "valibot" does not implement StandardJSONSchemaV1 (`~standard.jsonSchema`). Upgrade to a version that does, or wrap your JSON Schema with fromJsonSchema().
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a server that boots fine can still be broken for every client. The fix is the &lt;code&gt;toStandardJsonSchema()&lt;/code&gt; wrap above. Zod 4.2+ and ArkType 2.2 have &lt;code&gt;~standard.jsonSchema&lt;/code&gt; built in. I'd add a test that calls &lt;code&gt;listTools()&lt;/code&gt; once, so this fails in CI instead of in front of a user.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model sees in tools/list
&lt;/h2&gt;

&lt;p&gt;This is the part I care about most for MCP, because the JSON Schema is what the LLM reads when it decides how to call your tool. The SDK asks every library for draft 2020-12 and stamps &lt;code&gt;type: "object"&lt;/code&gt; on the root.&lt;/p&gt;

&lt;p&gt;Same fields, three different outputs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Zod 4.6.7&lt;/th&gt;
&lt;th&gt;Valibot 1.5.0&lt;/th&gt;
&lt;th&gt;ArkType 2.2.9&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;priority&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;enum + &lt;code&gt;type: "string"&lt;/code&gt; + &lt;code&gt;default: "normal"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;enum + &lt;code&gt;type: "string"&lt;/code&gt; + &lt;code&gt;default: "normal"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;enum only, sorted alphabetically, &lt;strong&gt;no default&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;maxItems&lt;/code&gt;, items, &lt;code&gt;default: []&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;maxItems&lt;/code&gt;, items, &lt;code&gt;default: []&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;maxItems&lt;/code&gt;, items, &lt;strong&gt;no default&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dueAt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;format: "date-time"&lt;/code&gt; + a long regex &lt;code&gt;pattern&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;format: "date-time"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ISO 8601 regex &lt;code&gt;pattern&lt;/code&gt;, no &lt;code&gt;format&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;assigneeEmail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;format: "email"&lt;/code&gt; + regex&lt;/td&gt;
&lt;td&gt;&lt;code&gt;format: "email"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;format: "email"&lt;/code&gt; + regex&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Valibot gave the cleanest schema. Zod's is correct but the datetime regex is about 400 characters of noise in every &lt;code&gt;tools/list&lt;/code&gt; response. ArkType dropping the defaults surprised me. The model has no way to know &lt;code&gt;priority&lt;/code&gt; falls back to &lt;code&gt;"normal"&lt;/code&gt;, so it'll either guess or always send it. If your tool descriptions rely on defaults, put them in &lt;code&gt;.describe()&lt;/code&gt; text as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation errors the model gets back
&lt;/h2&gt;

&lt;p&gt;When a call fails validation, the SDK returns &lt;code&gt;isError: true&lt;/code&gt; with the issues joined into one string. I sent &lt;code&gt;title: ""&lt;/code&gt;, &lt;code&gt;priority: "urgent"&lt;/code&gt;, 11 tags and &lt;code&gt;assigneeEmail: "nope"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;zod:     title: Too small: expected string to have &amp;gt;=1 characters, priority: Invalid option: expected one of "low"|"normal"|"high", tags: Too big: expected array to have &amp;lt;=10 items, assigneeEmail: Invalid email address
valibot: title: Invalid length: Expected &amp;gt;=1 but received 0, priority: Invalid type: Expected ("low" | "normal" | "high") but received "urgent", tags: Invalid length: Expected &amp;lt;=10 but received 11, assigneeEmail: Invalid email: Received "nope"
arktype: title: title must be non-empty, tags: tags must be at most length 10 (was 11), priority: priority must be "high", "low" or "normal" (was "urgent"), assigneeEmail: assigneeEmail must be an email address (was "nope")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three are good enough for a model to fix its own call. ArkType's read most like English. Valibot and ArkType echo the bad value back, which helps when the model sent something weird.&lt;/p&gt;

&lt;h2&gt;
  
  
  The datetime trap
&lt;/h2&gt;

&lt;p&gt;I'm in Pakistan (UTC+5), so the first thing I tried was local time with an offset, which is what I'd expect an assistant to send for someone here. Five strings in total:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Zod &lt;code&gt;z.iso.datetime()&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Valibot &lt;code&gt;isoTimestamp()&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;ArkType &lt;code&gt;string.date.iso&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2026-11-07T11:00:00Z&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2026-11-07T11:00:00+05:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;fail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;td&gt;pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2026-11-07&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;pass&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2026-11-07T11:00Z&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;pass&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;next friday&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zod's datetime only accepts &lt;code&gt;Z&lt;/code&gt; unless you opt in. I'd have shipped that bug without this test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;dueAt&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="nx"&gt;iso&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ArkType goes the other way. &lt;code&gt;string.date.iso&lt;/code&gt; is any ISO 8601 date, so a bare date passes. If the tool needs a full timestamp, that's the wrong keyword and you need your own pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse speed, and the ArkType clone setting
&lt;/h2&gt;

&lt;p&gt;First run, the full task schema with a valid input, then an invalid one, calling each library's normal parse (&lt;code&gt;safeParse&lt;/code&gt;, &lt;code&gt;v.safeParse&lt;/code&gt;, &lt;code&gt;arkTask(x)&lt;/code&gt;):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;valid input, mean&lt;/th&gt;
&lt;th&gt;invalid input, mean&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Zod&lt;/td&gt;
&lt;td&gt;679 ns&lt;/td&gt;
&lt;td&gt;1,612 ns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valibot&lt;/td&gt;
&lt;td&gt;710 ns&lt;/td&gt;
&lt;td&gt;1,149 ns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArkType&lt;/td&gt;
&lt;td&gt;4,914 ns&lt;/td&gt;
&lt;td&gt;11,634 ns&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's the opposite of ArkType's reputation, so I split the schema apart. With no defaults and no formats, ArkType was the fastest by far: 71 ns against Zod's 295 ns and Valibot's 453 ns. Adding the email check took it to 123 ns. Adding &lt;code&gt;.default()&lt;/code&gt; on any single field took it to about 4,900 ns.&lt;/p&gt;

&lt;p&gt;The cause is cloning. When a schema has morphs (and defaults count), ArkType clones the input before applying them so your original object isn't mutated. You can turn that off:&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;// arktype.config.ts, imported before anything that imports "arktype"&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;configure&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;arktype/config&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;clone: false&lt;/code&gt; the default-only schema ran at 55 ns, and the full task schema at 270 ns, which includes about 47 ns for the &lt;code&gt;{ ...good }&lt;/code&gt; spread I added so each call got a fresh object. The trade-off is real: with cloning off, ArkType writes &lt;code&gt;priority: "normal"&lt;/code&gt; into the object you passed in. I checked, and the returned value is the same object as the input. For MCP tool args I'm fine with that, since the args object comes straight from the parsed request. I ran the ArkType tool through the MCP client again with &lt;code&gt;clone: false&lt;/code&gt; and the handler still got &lt;code&gt;{"title":"x","tags":[],"priority":"normal"}&lt;/code&gt;. For shared config objects I'd leave cloning on.&lt;/p&gt;

&lt;p&gt;To be fair, even 5 µs per tool call doesn't matter next to an LLM round trip. I only went looking because the number looked wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cold start and bundle size
&lt;/h2&gt;

&lt;p&gt;Cold start matters more to me than parse speed, because my MCP servers on Vercel are &lt;a href="https://fabwaseem.com/blog/mcp-server-stateless" rel="noopener noreferrer"&gt;stateless and build a fresh server per request&lt;/a&gt;. I timed import plus first parse in a fresh Node process, 21 runs each, median:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;import + first parse&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valibot&lt;/td&gt;
&lt;td&gt;5.8 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zod&lt;/td&gt;
&lt;td&gt;43.7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArkType&lt;/td&gt;
&lt;td&gt;159.6 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bundle size, esbuild &lt;code&gt;--bundle --minify --format=esm --platform=browser&lt;/code&gt;, entry imports one schema and calls parse once:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;minified&lt;/th&gt;
&lt;th&gt;gzip&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valibot&lt;/td&gt;
&lt;td&gt;5.3 KB&lt;/td&gt;
&lt;td&gt;2.0 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Valibot + &lt;code&gt;@valibot/to-json-schema&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;19.2 KB&lt;/td&gt;
&lt;td&gt;5.3 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zod Mini (&lt;code&gt;zod/mini&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;20.9 KB&lt;/td&gt;
&lt;td&gt;7.1 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zod&lt;/td&gt;
&lt;td&gt;85.4 KB&lt;/td&gt;
&lt;td&gt;25.1 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ArkType&lt;/td&gt;
&lt;td&gt;156.4 KB&lt;/td&gt;
&lt;td&gt;47.6 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zod and ArkType include JSON Schema output already, so adding the conversion call barely changed their size. Valibot needs the extra package, and it's still the smallest. On a server none of these sizes matter much, but the cold start difference follows the same order.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm doing in my own MCP servers
&lt;/h2&gt;

&lt;p&gt;I'm keeping Zod in the NestJS server. It's what the MCP and AI SDK examples use, its JSON Schema output was accurate here, and I already know its quirks. Any datetime field I add there gets &lt;code&gt;{ offset: true }&lt;/code&gt; from now on.&lt;/p&gt;

&lt;p&gt;For the next serverless MCP endpoint I build, I'll try Valibot. It had the cleanest &lt;code&gt;tools/list&lt;/code&gt; output and the fastest cold start, and the only extra step is one &lt;code&gt;toStandardJsonSchema()&lt;/code&gt; wrap.&lt;/p&gt;

&lt;p&gt;ArkType I'd pick for hot validation paths outside MCP, where the defaults-clone cost is easy to turn off and I don't care what JSON Schema it emits. For MCP tools specifically, the missing defaults in &lt;code&gt;tools/list&lt;/code&gt; put me off.&lt;/p&gt;

&lt;p&gt;I only tested one schema shape on one VM. If your schemas are mostly nested unions or recursive types, the order could change, and I'd like to hear what you see. The scripts are small enough to rerun in a few minutes. If you're testing your own server end to end, my notes on &lt;a href="https://fabwaseem.com/blog/test-mcp-server" rel="noopener noreferrer"&gt;testing an MCP server&lt;/a&gt; and &lt;a href="https://fabwaseem.com/blog/mcp-security" rel="noopener noreferrer"&gt;MCP security&lt;/a&gt; cover the rest of what I check before shipping.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can I use Valibot or ArkType instead of Zod for MCP tool schemas?
&lt;/h3&gt;

&lt;p&gt;Yes. The MCP TypeScript SDK v2 (I tested @modelcontextprotocol/server 2.4.0) accepts any Standard Schema that also implements ~standard.jsonSchema. Zod 4.2+ and ArkType 2.2 do this out of the box. Valibot needs toStandardJsonSchema() from @valibot/to-json-schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my Valibot MCP tool fail at tools/list?
&lt;/h3&gt;

&lt;p&gt;A plain Valibot schema has no ~standard.jsonSchema, so the SDK can't turn it into JSON Schema. registerTool accepts it, but tools/list throws 'Schema library "valibot" does not implement StandardJSONSchemaV1'. Wrap the schema with toStandardJsonSchema().&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is ArkType slower than Zod in my benchmark?
&lt;/h3&gt;

&lt;p&gt;In my test ArkType was the fastest on a schema without defaults (71 ns vs Zod 295 ns), but any .default() made it clone the input first, which pushed a parse to about 4.9 microseconds. configure({ clone: false }) from arktype/config brought the full schema to 270 ns, at the cost of mutating the input object.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Zod accept ISO datetimes with a timezone offset?
&lt;/h3&gt;

&lt;p&gt;Not by default. In Zod 4.6.7, z.iso.datetime() accepted 2026-11-07T11:00:00Z but rejected 2026-11-07T11:00:00+05:00. Use z.iso.datetime({ offset: true }) if clients or LLMs may send local times.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which validation library has the smallest bundle?
&lt;/h3&gt;

&lt;p&gt;Valibot. In my esbuild test the task schema bundled to 2.0 KB gzip with Valibot (5.3 KB with JSON Schema conversion), 7.1 KB with zod/mini, 25.1 KB with Zod and 47.6 KB with ArkType.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://fabwaseem.com/blog/zod-vs-valibot-vs-arktype" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>typescript</category>
      <category>node</category>
      <category>performance</category>
    </item>
    <item>
      <title>NestJS Rate Limiting: In-Memory vs Redis vs Postgres</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 06 Nov 2026 06:18:16 +0000</pubDate>
      <link>https://dev.to/fabwaseem/nestjs-rate-limiting-in-memory-vs-redis-vs-postgres-o0m</link>
      <guid>https://dev.to/fabwaseem/nestjs-rate-limiting-in-memory-vs-redis-vs-postgres-o0m</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/nestjs-rate-limiting-redis-vs-postgres" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I shipped a NestJS API with the default &lt;code&gt;@nestjs/throttler&lt;/code&gt; storage last year and only noticed the hole when we ran two instances behind a load balancer. Each process kept its own counter, so a client that should have been cut off at 100 requests in a minute quietly got closer to 200. Today I re-tested that on NestJS 12 with three storages on the same box: in-memory, Redis, and Postgres.&lt;/p&gt;

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

&lt;p&gt;Everything ran on one 8 vCPU / 16 GB Linux VM on November 6, 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 24.22.1&lt;/strong&gt;, &lt;strong&gt;NestJS 12.3.3&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;@nestjs/throttler&lt;/code&gt; 6.7.1&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis 7.2.11&lt;/strong&gt; with persistence off, via &lt;strong&gt;&lt;code&gt;@nestjs-redis/throttler-storage&lt;/code&gt; 2.0.1&lt;/strong&gt; and the &lt;code&gt;redis&lt;/code&gt; 5.12.1 client&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 18.6&lt;/strong&gt; with a small custom storage that upserts into one &lt;code&gt;throttler_hits&lt;/code&gt; table&lt;/li&gt;
&lt;li&gt;Limit: &lt;strong&gt;100 requests / 60 seconds&lt;/strong&gt;, tracker keyed on &lt;code&gt;x-forwarded-for&lt;/code&gt; so localhost tests don't all share &lt;code&gt;127.0.0.1&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I measured three things: p50/p95 latency of allowed requests, whether request 101 returns 429, and whether two Nest processes behind the same storage still stop at 100 total.&lt;/p&gt;

&lt;p&gt;If you're comparing Nest to thinner frameworks, I wrote about &lt;a href="https://fabwaseem.com/blog/hono-vs-express" rel="noopener noreferrer"&gt;Hono vs Express&lt;/a&gt; and the &lt;a href="https://fabwaseem.com/blog/nestjs-12" rel="noopener noreferrer"&gt;NestJS 12 upgrade&lt;/a&gt; separately. Redis vs Postgres as infrastructure shows up again in my &lt;a href="https://fabwaseem.com/blog/postgres-job-queue-pg-boss-vs-bullmq" rel="noopener noreferrer"&gt;pg-boss vs BullMQ&lt;/a&gt; and &lt;a href="https://fabwaseem.com/blog/ai-automation-pipeline-nodejs-bullmq" rel="noopener noreferrer"&gt;BullMQ pipeline&lt;/a&gt; posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring &lt;code&gt;@nestjs/throttler&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The default setup is tiny. Global guard, one named throttler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ThrottlerModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forRoot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;throttlers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;getTracker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;xf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;if &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;xf&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;xf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;xf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="c1"&gt;// providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That uses the built-in in-memory storage. Fine for a single process. Wrong the moment you scale out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redis storage
&lt;/h2&gt;

&lt;p&gt;I used &lt;code&gt;@nestjs-redis/throttler-storage&lt;/code&gt;, which implements &lt;code&gt;ThrottlerStorage&lt;/code&gt; with a Lua script so the increment is atomic:&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;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;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;REDIS_URL&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;ThrottlerModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forRoot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;throttlers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;storage&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;RedisThrottlerStorage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;getTracker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="cm"&gt;/* same as above */&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;Pass an existing client. The package does not manage connect/disconnect for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres storage (custom)
&lt;/h2&gt;

&lt;p&gt;There isn't an official Nest Postgres throttler storage, so I wrote one. One row per key, upsert that resets when the window expires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;throttler_hits&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;is_blocked&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;block_expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&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;// inside increment():&lt;/span&gt;
&lt;span class="nx"&gt;INSERT&lt;/span&gt; &lt;span class="nx"&gt;INTO&lt;/span&gt; &lt;span class="nf"&gt;throttler_hits &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;VALUES &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$2&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; milliseconds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;ON&lt;/span&gt; &lt;span class="nc"&gt;CONFLICT &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;DO&lt;/span&gt; &lt;span class="nx"&gt;UPDATE&lt;/span&gt; &lt;span class="nx"&gt;SET&lt;/span&gt;
  &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CASE&lt;/span&gt; &lt;span class="nx"&gt;WHEN&lt;/span&gt; &lt;span class="nx"&gt;throttler_hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;THEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
              &lt;span class="nx"&gt;ELSE&lt;/span&gt; &lt;span class="nx"&gt;throttler_hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="nx"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CASE&lt;/span&gt; &lt;span class="nx"&gt;WHEN&lt;/span&gt; &lt;span class="nx"&gt;throttler_hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                    &lt;span class="nx"&gt;THEN&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$2&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; milliseconds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nx"&gt;interval&lt;/span&gt;
                    &lt;span class="nx"&gt;ELSE&lt;/span&gt; &lt;span class="nx"&gt;throttler_hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expires_at&lt;/span&gt; &lt;span class="nx"&gt;END&lt;/span&gt;
&lt;span class="nx"&gt;RETURNING&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;is_blocked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;block_expires_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrap that in a transaction and set &lt;code&gt;is_blocked&lt;/code&gt; when &lt;code&gt;hits &amp;gt; limit&lt;/code&gt;, matching how the in-memory storage behaves. No &lt;code&gt;SKIP LOCKED&lt;/code&gt; here: a single-row upsert is enough for a counter. &lt;code&gt;SKIP LOCKED&lt;/code&gt; is the pattern I use for job claiming, not for rate-limit tallies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency (allowed requests)
&lt;/h2&gt;

&lt;p&gt;Eighty requests under the limit, median of three runs. Raw Node &lt;code&gt;http&lt;/code&gt; with no Nest is the baseline.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;p50 (ms)&lt;/th&gt;
&lt;th&gt;p95 (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;none (raw http)&lt;/td&gt;
&lt;td&gt;0.34&lt;/td&gt;
&lt;td&gt;0.57&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory&lt;/td&gt;
&lt;td&gt;0.36&lt;/td&gt;
&lt;td&gt;0.64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redis&lt;/td&gt;
&lt;td&gt;0.38&lt;/td&gt;
&lt;td&gt;0.81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.42&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On localhost, memory and Redis are basically free. Postgres adds about &lt;strong&gt;0.6 ms&lt;/strong&gt; at p50 versus the raw baseline. That is one round trip plus the upsert. Across a network to a remote Postgres the gap will be larger; across a network to Redis it will grow too, just usually less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reject correctness
&lt;/h2&gt;

&lt;p&gt;Single process, &lt;code&gt;LIMIT + 20&lt;/code&gt; requests with the same client IP:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;200s&lt;/th&gt;
&lt;th&gt;429s&lt;/th&gt;
&lt;th&gt;First 429 at request #&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;memory&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redis&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three cut off exactly after 100. So the bug is not "throttler is wrong". The bug is sharing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-instance correctness
&lt;/h2&gt;

&lt;p&gt;Two Nest processes, same Redis or Postgres (or none for memory), alternating requests for the same IP, &lt;code&gt;LIMIT + 30 = 130&lt;/code&gt; total:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Storage&lt;/th&gt;
&lt;th&gt;Allowed&lt;/th&gt;
&lt;th&gt;Blocked&lt;/th&gt;
&lt;th&gt;Shared limit held?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;memory&lt;/td&gt;
&lt;td&gt;130&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redis&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Memory allowed every request. Each process only saw 65 hits, both under 100. That is the load-balancer failure mode. Redis and Postgres stopped at 100 total across both processes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd pick
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One instance, local or edge:&lt;/strong&gt; in-memory. Lowest moving parts. Don't pretend it will survive a second replica.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Already running Redis&lt;/strong&gt; (BullMQ, cache, sessions): use &lt;code&gt;@nestjs-redis/throttler-storage&lt;/code&gt;. Latency matched memory on this box and shared correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres only, no Redis:&lt;/strong&gt; the custom upsert storage works. Expect roughly a millisecond of overhead on localhost, more over the network. Keep the table tiny and index-free beyond the primary key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would not put rate-limit counters in the same hot transactional tables as orders or payments. A dedicated table (or Redis) keeps the lock footprint small.&lt;/p&gt;

&lt;p&gt;One caveat: this is one machine, client and storage on localhost, Nest 12.3.3, throttler 6.7.1. It answers "does shared storage actually share" and "how much does each storage add on this box". It does not answer global multi-region limits or abuse at the CDN layer. Put a WAF or edge limit in front when that matters.&lt;/p&gt;

&lt;p&gt;If you only remember one number from today: &lt;strong&gt;memory let 130 through where Redis and Postgres stopped at 100&lt;/strong&gt;. That is the whole reason to change the storage.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does NestJS @nestjs/throttler work across multiple instances?
&lt;/h3&gt;

&lt;p&gt;Only if you swap the default in-memory storage for a shared one. With two Nest processes and a limit of 100, in-memory allowed all 130 alternating requests in my test because each process only saw 65. Redis and Postgres both stopped at 100 total.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Redis storage should I use with @nestjs/throttler?
&lt;/h3&gt;

&lt;p&gt;I used @nestjs-redis/throttler-storage 2.0.1 with the redis 5.x client on NestJS 12. You pass an existing Redis client; the package runs the increment in a Lua script so it stays atomic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I store NestJS rate limits in Postgres?
&lt;/h3&gt;

&lt;p&gt;Yes, with a custom ThrottlerStorage. I used a single throttler_hits table and an INSERT ... ON CONFLICT upsert that resets hits when the TTL window expires. On localhost it added about 0.6 ms at p50 versus a raw HTTP baseline.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much latency does NestJS rate limiting add?
&lt;/h3&gt;

&lt;p&gt;On one 8 vCPU box with localhost storage, allowed-request p50 was 0.36 ms for in-memory, 0.38 ms for Redis 7.2.11, and 1.00 ms for PostgreSQL 18.6, against a 0.34 ms raw HTTP baseline. Remote Redis or Postgres will cost more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is in-memory rate limiting OK for production NestJS?
&lt;/h3&gt;

&lt;p&gt;It is fine for a single instance. The moment you run two replicas behind a load balancer, each keeps its own counter, so clients can exceed the limit. Switch to Redis or Postgres (or an edge/WAF limit) before you scale out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://fabwaseem.com/blog/nestjs-rate-limiting-redis-vs-postgres" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>redis</category>
      <category>node</category>
      <category>security</category>
    </item>
    <item>
      <title>Run TypeScript in Node.js Without a Build Step: Type Stripping Tested on Node 22, 24 and 26</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Wed, 04 Nov 2026 18:23:40 +0000</pubDate>
      <link>https://dev.to/fabwaseem/run-typescript-in-nodejs-without-a-build-step-type-stripping-tested-on-node-22-24-and-26-430a</link>
      <guid>https://dev.to/fabwaseem/run-typescript-in-nodejs-without-a-build-step-type-stripping-tested-on-node-22-24-and-26-430a</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/node-typescript-type-stripping" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You can now run a &lt;code&gt;.ts&lt;/code&gt; file with plain &lt;code&gt;node app.ts&lt;/code&gt;, no &lt;code&gt;tsx&lt;/code&gt;, no &lt;code&gt;ts-node&lt;/code&gt;, no build step. Type stripping has been &lt;strong&gt;on by default since Node.js 22.18.0 and 23.6.0&lt;/strong&gt;, and it became &lt;strong&gt;stable in Node.js 24.12.0 and 25.2.0&lt;/strong&gt;. On Node.js 26 LTS it is simply how Node works.&lt;/p&gt;

&lt;p&gt;But "Node runs TypeScript" has sharp edges. Enums fail. Parameter properties fail. Decorators fail. Imports without &lt;code&gt;.ts&lt;/code&gt; fail. And Node never checks your types. I tested every one of these on &lt;strong&gt;Node.js 22.20.0, 24.22.2 and 26.13.0&lt;/strong&gt;, and this post shows the exact errors, the &lt;code&gt;tsconfig.json&lt;/code&gt; that catches all of them before runtime, and how startup time compares to &lt;code&gt;tsx&lt;/code&gt; and precompiled JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Node.js type stripping works
&lt;/h2&gt;

&lt;p&gt;Node replaces TypeScript type annotations with whitespace and runs what is left as JavaScript. It does &lt;strong&gt;not&lt;/strong&gt; type-check, it &lt;strong&gt;ignores &lt;code&gt;tsconfig.json&lt;/code&gt;&lt;/strong&gt;, and it only accepts &lt;strong&gt;erasable&lt;/strong&gt; syntax: TypeScript that disappears completely when you delete the types.&lt;/p&gt;

&lt;p&gt;From the &lt;a href="https://nodejs.org/docs/latest/api/typescript.html" rel="noopener noreferrer"&gt;Node.js TypeScript docs&lt;/a&gt; history table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Node.js version&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v22.6.0&lt;/td&gt;
&lt;td&gt;Type stripping added behind &lt;code&gt;--experimental-strip-types&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v23.6.0, v22.18.0&lt;/td&gt;
&lt;td&gt;Enabled by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v24.3.0, v22.18.0&lt;/td&gt;
&lt;td&gt;No more experimental warning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v24.12.0, v25.2.0&lt;/td&gt;
&lt;td&gt;Marked &lt;strong&gt;Stable&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v26.0.0&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--experimental-transform-types&lt;/code&gt; flag &lt;strong&gt;removed&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can check it at runtime: &lt;code&gt;node -p "process.features.typescript"&lt;/code&gt; printed &lt;code&gt;strip&lt;/code&gt; on all three versions I tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  What works out of the box
&lt;/h2&gt;

&lt;p&gt;This file ran unchanged with &lt;code&gt;node ok.ts&lt;/code&gt; on Node 22.20, 24.22 and 26.13:&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;// ok.ts&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;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="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="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="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Stats&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;greet&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;./util.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Waseem&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;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="kr"&gt;number&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;xs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Stats&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;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="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// hi Waseem admin 3 undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Interfaces, type aliases, generics, &lt;code&gt;as&lt;/code&gt; casts, &lt;code&gt;import type&lt;/code&gt; and annotated functions are all erasable, so they just work. Add &lt;code&gt;"type": "module"&lt;/code&gt; to &lt;code&gt;package.json&lt;/code&gt;; without it, Node prints a &lt;code&gt;MODULE_TYPELESS_PACKAGE_JSON&lt;/code&gt; warning and reparses the file as ESM.&lt;/p&gt;

&lt;h2&gt;
  
  
  What fails (tested on Node 22, 24 and 26)
&lt;/h2&gt;

&lt;p&gt;All three versions gave the same result for every case below.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TypeScript feature&lt;/th&gt;
&lt;th&gt;What Node prints&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enum Color { Red, Green }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;namespace A { export const x = 1 }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript namespace declaration is not supported in strip-only mode&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;constructor(public name: string)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;import { greet } from './util'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../util'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;import { User } from './types.ts'&lt;/code&gt; (User is an interface)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SyntaxError: The requested module './types.ts' does not provide an export named 'User'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@log class K {}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SyntaxError: Invalid or unexpected token&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;code&gt;.tsx&lt;/code&gt; file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".tsx"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;code&gt;.ts&lt;/code&gt; file inside &lt;code&gt;node_modules&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Error [ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of these surprise people the most:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Type-only imports must say &lt;code&gt;import type&lt;/code&gt;.&lt;/strong&gt; Node removes the type but keeps the import statement, so it looks for a runtime export called &lt;code&gt;User&lt;/code&gt; and crashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decorators don't run.&lt;/strong&gt; That means NestJS, TypeORM entities and anything with &lt;code&gt;@Injectable()&lt;/code&gt; still needs a compile step or a loader like &lt;code&gt;tsx&lt;/code&gt; or SWC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packages can't ship raw &lt;code&gt;.ts&lt;/code&gt;.&lt;/strong&gt; Node refuses to strip types under &lt;code&gt;node_modules&lt;/code&gt;, so libraries still need to publish JavaScript.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--experimental-transform-types&lt;/code&gt; is gone in Node 26
&lt;/h3&gt;

&lt;p&gt;On Node 24, &lt;code&gt;node --experimental-transform-types enum.ts&lt;/code&gt; ran the enum (with an &lt;code&gt;ExperimentalWarning&lt;/code&gt;). On Node 26.13.0 the same command printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node: bad option: --experimental-transform-types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you relied on that flag for enums, Node 26 is the point where you have to remove them or go back to a loader.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tsconfig.json that catches every failure
&lt;/h2&gt;

&lt;p&gt;Node ignores &lt;code&gt;tsconfig.json&lt;/code&gt;, but your editor and &lt;code&gt;tsc&lt;/code&gt; don't. With this config, &lt;code&gt;tsc&lt;/code&gt; flagged every runtime failure above (except decorators, see below) before I ran anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"esnext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodenext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"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;"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;"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;"strict"&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;"skipLibCheck"&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="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;Output from &lt;code&gt;tsc -p .&lt;/code&gt; with TypeScript 5.9.3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;enum.ts(1,6): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
ns.ts(1,11): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
paramprop.ts(1,23): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
noext.ts(1,23): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.
typeimport.ts(1,10): error TS1484: 'User' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each option does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;erasableSyntaxOnly&lt;/code&gt;&lt;/strong&gt; (TypeScript 5.8+) errors on enums, namespaces and parameter properties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;verbatimModuleSyntax&lt;/code&gt;&lt;/strong&gt; forces &lt;code&gt;import type&lt;/code&gt; for type-only imports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;allowImportingTsExtensions&lt;/code&gt;&lt;/strong&gt; lets you write &lt;code&gt;./util.ts&lt;/code&gt;, which is what Node needs. It requires &lt;code&gt;noEmit&lt;/code&gt; (or &lt;code&gt;rewriteRelativeImportExtensions&lt;/code&gt; if you also emit).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;module: nodenext&lt;/code&gt;&lt;/strong&gt; makes &lt;code&gt;tsc&lt;/code&gt; require file extensions like Node does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The gap: decorators.&lt;/strong&gt; TypeScript 5.9 accepts standard decorators, so &lt;code&gt;tsc&lt;/code&gt; passed &lt;code&gt;dec.ts&lt;/code&gt;, but Node 26 still threw a &lt;code&gt;SyntaxError&lt;/code&gt;. If your code uses decorators, keep a build step for that code.&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;tsc --noEmit&lt;/code&gt; in CI and in a pre-commit hook. Node won't check types for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Replacing enums
&lt;/h3&gt;

&lt;p&gt;The usual erasable replacement is a &lt;code&gt;const&lt;/code&gt; object plus a type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Color&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Red&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Green&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="kd"&gt;as 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;type&lt;/span&gt; &lt;span class="nx"&gt;Color&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;Color&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;Color&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Startup time: node vs tsx vs precompiled JS
&lt;/h2&gt;

&lt;p&gt;Same &lt;code&gt;ok.ts&lt;/code&gt; (two small files), Node 26.13.0, 30 interleaved runs each, wall time for the whole process:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How it ran&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;p90&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;node js/ok.js&lt;/code&gt; (precompiled with &lt;code&gt;tsc&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;27.7 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;33.0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;node ok.ts&lt;/code&gt; (type stripping)&lt;/td&gt;
&lt;td&gt;62.6 ms&lt;/td&gt;
&lt;td&gt;67.2 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsx ok.ts&lt;/code&gt; (tsx 4.23.15)&lt;/td&gt;
&lt;td&gt;116.9 ms&lt;/td&gt;
&lt;td&gt;128.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Built-in stripping was about &lt;strong&gt;half the startup time of &lt;code&gt;tsx&lt;/code&gt;&lt;/strong&gt;, but about &lt;strong&gt;35 ms slower than running precompiled JavaScript&lt;/strong&gt; on this machine. For scripts, CLIs, dev servers and tests that difference doesn't matter. For a serverless function where cold start counts, shipping compiled JavaScript is still faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use built-in type stripping
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;node file.ts&lt;/code&gt; for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scripts, seeds, migrations and one-off tools&lt;/li&gt;
&lt;li&gt;CLIs and internal services you control end to end&lt;/li&gt;
&lt;li&gt;Running tests with &lt;code&gt;node --test&lt;/code&gt; on &lt;code&gt;.ts&lt;/code&gt; files&lt;/li&gt;
&lt;li&gt;Dev mode with &lt;code&gt;node --watch src/server.ts&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Keep a build step (tsc, SWC, esbuild or tsx) for:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;NestJS and anything else that uses decorators&lt;/li&gt;
&lt;li&gt;React and &lt;code&gt;.tsx&lt;/code&gt; files&lt;/li&gt;
&lt;li&gt;Libraries you publish to npm&lt;/li&gt;
&lt;li&gt;Code that relies on &lt;code&gt;paths&lt;/code&gt; aliases in &lt;code&gt;tsconfig.json&lt;/code&gt; (Node ignores them)&lt;/li&gt;
&lt;li&gt;Serverless functions where cold start matters&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're upgrading Node at the same time, see &lt;a href="https://fabwaseem.com/blog/node-24-vs-node-26" rel="noopener noreferrer"&gt;Node 24 vs Node 26&lt;/a&gt; for the other breaking changes, and &lt;a href="https://fabwaseem.com/blog/typescript-7" rel="noopener noreferrer"&gt;TypeScript 7&lt;/a&gt; for where the TypeScript compiler itself is going.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can Node.js run TypeScript directly?
&lt;/h3&gt;

&lt;p&gt;Yes. Since Node.js 22.18.0 and 23.6.0, node file.ts works without a flag, and the feature is marked stable from Node.js 24.12.0 and 25.2.0. Node only strips types; it does not type-check and it does not support enums, namespaces, parameter properties or decorators.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Node.js type stripping check types?
&lt;/h3&gt;

&lt;p&gt;No. Node replaces types with whitespace and runs the result. A file with type errors runs fine as long as the JavaScript is valid. Run tsc --noEmit separately to check types.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my enum fail in Node.js?
&lt;/h3&gt;

&lt;p&gt;Enums generate runtime code, so they aren't erasable. Node prints ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: TypeScript enum is not supported in strip-only mode. Replace them with a const object and a union type, and turn on erasableSyntaxOnly so tsc warns you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is --experimental-transform-types available in Node 26?
&lt;/h3&gt;

&lt;p&gt;No. It was removed in Node.js 26.0.0. On Node 26.13.0 the flag fails with bad option: --experimental-transform-types. It still works with a warning on Node 24.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need tsx or ts-node?
&lt;/h3&gt;

&lt;p&gt;Only for code that uses non-erasable syntax, decorators, .tsx, or tsconfig paths. For plain erasable TypeScript, node is enough and started about twice as fast as tsx in my test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do I need .ts in import paths?
&lt;/h3&gt;

&lt;p&gt;Node resolves files exactly like it does for JavaScript ESM: no extension guessing. import './util' fails with ERR_MODULE_NOT_FOUND; write import './util.ts' and enable allowImportingTsExtensions in tsconfig.json.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://fabwaseem.com/blog/node-typescript-type-stripping" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>node</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>NestJS vs Hono vs Fastify (2026): Benchmarked on Node.js 26</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Wed, 04 Nov 2026 18:23:34 +0000</pubDate>
      <link>https://dev.to/fabwaseem/nestjs-vs-hono-vs-fastify-2026-benchmarked-on-nodejs-26-41dp</link>
      <guid>https://dev.to/fabwaseem/nestjs-vs-hono-vs-fastify-2026-benchmarked-on-nodejs-26-41dp</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/nestjs-vs-hono-vs-fastify" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"NestJS vs Hono vs Fastify" is one of the most common backend questions I see from TypeScript developers in 2026, and most answers are either opinion or benchmarks from 2023. So I ran my own: the same three routes on &lt;strong&gt;Express 5.3, Fastify 5.12, Hono 4.13, NestJS 12.3 on Express and NestJS 12.3 on Fastify&lt;/strong&gt;, all on Node.js 26.13 (the current LTS), on the same machine, with the same load generator.&lt;/p&gt;

&lt;p&gt;This post gives you the numbers, the exact code, and a straight answer on which one to pick for an API, a SaaS backend or an edge function.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Machine:&lt;/strong&gt; one 8-vCPU Intel Xeon Linux VM. The server was pinned to &lt;strong&gt;one CPU core&lt;/strong&gt; with &lt;code&gt;taskset -c 0&lt;/code&gt; (Node runs your JavaScript on one thread, so this is a fair per-process number). The load generator, autocannon 8, ran on 6 other cores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime:&lt;/strong&gt; Node.js v26.13.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Versions:&lt;/strong&gt; express 5.3.0, fastify 5.12.8, hono 4.13.16 with @hono/node-server 2.1.6, @nestjs/core 12.3.3 with @nestjs/platform-express and @nestjs/platform-fastify 12.3.3. NestJS was compiled with TypeScript 5.9.3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load:&lt;/strong&gt; 100 connections, no pipelining, 3-second warm-up, then 8 seconds per route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rounds:&lt;/strong&gt; 3 full rounds, run one framework after another (interleaved), and I report the &lt;strong&gt;median&lt;/strong&gt; of the 3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routes:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;GET /json&lt;/code&gt; returns &lt;code&gt;{"hello":"world"}&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /users/:id&lt;/code&gt; returns the route parameter as JSON&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /echo&lt;/code&gt; parses a small JSON body and sends it back&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No logging, no validation, no database. This measures framework overhead only. Every request returned 2xx.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Express 5:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&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;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="na"&gt;hello&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;/users/:id&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="na"&gt;id&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;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/echo&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fastify 5:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Fastify&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;fastify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Fastify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;app&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;/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hello&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;/users/:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/echo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Hono 4 on Node.js:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Hono&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;hono&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;serve&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;@hono/node-server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&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;Hono&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hello&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;/users/:id&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;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/echo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;NestJS 12&lt;/strong&gt; (one file, the adapter is picked by an env variable):&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reflect-metadata&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;Body&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;Get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Post&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;@nestjs/common&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NestFactory&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;@nestjs/core&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;FastifyAdapter&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;@nestjs/platform-fastify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;hello&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&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;users/:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;echo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;echo&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;AppController&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppModule&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bootstrap&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ADAPTER&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fastify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;NestFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AppModule&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;FastifyAdapter&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;NestFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AppModule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;bootstrap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Results: requests per second (one core, higher is better)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;GET /json&lt;/th&gt;
&lt;th&gt;GET /users/:id&lt;/th&gt;
&lt;th&gt;POST /echo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fastify 5.12&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;85,448&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;82,040&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;59,746&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hono 4.13 (Node)&lt;/td&gt;
&lt;td&gt;83,184&lt;/td&gt;
&lt;td&gt;80,728&lt;/td&gt;
&lt;td&gt;51,192&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NestJS 12 + Fastify&lt;/td&gt;
&lt;td&gt;70,082&lt;/td&gt;
&lt;td&gt;69,010&lt;/td&gt;
&lt;td&gt;48,596&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Express 5.3&lt;/td&gt;
&lt;td&gt;48,564&lt;/td&gt;
&lt;td&gt;45,476&lt;/td&gt;
&lt;td&gt;32,969&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NestJS 12 + Express&lt;/td&gt;
&lt;td&gt;43,844&lt;/td&gt;
&lt;td&gt;39,340&lt;/td&gt;
&lt;td&gt;29,563&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Median of 3 rounds. Latency was low for all of them at this load: p50 was 1-3 ms and p99 was 2-11 ms. The highest p99 (11 ms) was NestJS on Express for &lt;code&gt;POST /echo&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: startup time and memory
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Start to first 200&lt;/th&gt;
&lt;th&gt;RSS idle&lt;/th&gt;
&lt;th&gt;RSS after load&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hono 4.13&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;90 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;65.6 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;110.5 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Express 5.3&lt;/td&gt;
&lt;td&gt;115 ms&lt;/td&gt;
&lt;td&gt;66.9 MB&lt;/td&gt;
&lt;td&gt;115.7 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fastify 5.12&lt;/td&gt;
&lt;td&gt;158 ms&lt;/td&gt;
&lt;td&gt;73.8 MB&lt;/td&gt;
&lt;td&gt;116.5 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NestJS 12 + Fastify&lt;/td&gt;
&lt;td&gt;404 ms&lt;/td&gt;
&lt;td&gt;104.9 MB&lt;/td&gt;
&lt;td&gt;136.2 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NestJS 12 + Express&lt;/td&gt;
&lt;td&gt;486 ms&lt;/td&gt;
&lt;td&gt;105.2 MB&lt;/td&gt;
&lt;td&gt;149.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Start" is the time from spawning the process to the first successful response, polled every 5 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers say
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Fastify and Hono are close; Express is roughly half as fast.&lt;/strong&gt; On simple GET routes Fastify and Hono were within 3% of each other. Both did about 1.7-1.8x the requests of Express 5. Express 5 is a big cleanup (async errors, better path matching), but it did not change the performance picture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Fastify wins on JSON bodies.&lt;/strong&gt; On &lt;code&gt;POST /echo&lt;/code&gt;, Fastify did 17% more requests than Hono (59,746 vs 51,192). Fastify has its own body parser and a compiled serializer path. Hono on Node goes through the Web &lt;code&gt;Request&lt;/code&gt; adapter in &lt;code&gt;@hono/node-server&lt;/code&gt;, and &lt;code&gt;c.req.json()&lt;/code&gt; pays for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. NestJS costs you about 16-19% on Fastify, and the adapter matters more than the framework.&lt;/strong&gt; NestJS on Fastify kept 81-84% of raw Fastify's throughput on every route. NestJS on Express kept 87-90% of raw Express. But &lt;strong&gt;NestJS + Fastify was 60-75% faster than NestJS + Express&lt;/strong&gt; on GET routes. If you already run NestJS, switching the adapter is the cheapest performance win you have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. NestJS starts 3-5x slower and uses ~40 MB more memory.&lt;/strong&gt; That's the DI container, the module scan and reflect-metadata. It does not matter for a long-running API. It does matter for serverless cold starts and for scale-to-zero containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Hono starts fastest and uses the least memory.&lt;/strong&gt; 90 ms and 65.6 MB idle. Add the fact that the same Hono app runs on Cloudflare Workers, Bun, Deno and Vercel, and it is the best default for small services and edge functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats (read these before quoting the numbers)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;This is &lt;strong&gt;framework overhead with trivial handlers&lt;/strong&gt;. A real handler that hits Postgres for 2-5 ms will flatten these differences. At 30,000+ requests per second per core, the framework is rarely your bottleneck.&lt;/li&gt;
&lt;li&gt;One VM, one core per server, 100 connections. Your hardware will give different absolute numbers; the ranking is what to take away.&lt;/li&gt;
&lt;li&gt;Express used &lt;code&gt;express.json()&lt;/code&gt;; Fastify and Nest used their default parsers. No schemas, so Fastify's schema-based serialization was &lt;strong&gt;not&lt;/strong&gt; used. Fastify can serialize faster with a response schema; I did not test that here.&lt;/li&gt;
&lt;li&gt;Hono was tested on Node.js through &lt;code&gt;@hono/node-server&lt;/code&gt;. Hono on Bun or Workers is a different test.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which one should you choose?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If you are building...&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A large team API with modules, DI, guards, OpenAPI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;NestJS on the Fastify adapter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Structure and conventions; the Fastify adapter kept 81-84% of raw Fastify speed here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A high-throughput Node.js JSON API&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Fastify&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fastest here, especially on POST bodies; mature plugin ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge functions, Workers, multi-runtime code, small services&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Hono&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Near-Fastify speed on Node, fastest startup, smallest memory, runs everywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An existing Express app&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Stay on Express 5&lt;/strong&gt; unless you have a measured performance problem&lt;/td&gt;
&lt;td&gt;Migration cost is real; the gap only matters at high load&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My own default: &lt;strong&gt;Hono&lt;/strong&gt; for small services and anything that might run on the edge, &lt;strong&gt;NestJS with Fastify&lt;/strong&gt; when a SaaS backend needs structure and several developers. I covered the Express side in more depth in &lt;a href="https://fabwaseem.com/blog/hono-vs-express" rel="noopener noreferrer"&gt;Hono vs Express&lt;/a&gt;, and what changed in NestJS itself in &lt;a href="https://fabwaseem.com/blog/nestjs-12" rel="noopener noreferrer"&gt;NestJS 12&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to reproduce
&lt;/h2&gt;

&lt;p&gt;Install the same versions, start each server pinned to one core, and run autocannon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i hono@4.13.16 @hono/node-server@2.1.6 fastify@5.12.8 express@5.3.0 &lt;span class="se"&gt;\&lt;/span&gt;
  @nestjs/core@12.3.3 @nestjs/common@12.3.3 @nestjs/platform-express@12.3.3 &lt;span class="se"&gt;\&lt;/span&gt;
  @nestjs/platform-fastify@12.3.3 reflect-metadata rxjs autocannon@8

taskset &lt;span class="nt"&gt;-c&lt;/span&gt; 0 node fastify.mjs &amp;amp;
npx autocannon &lt;span class="nt"&gt;-c&lt;/span&gt; 100 &lt;span class="nt"&gt;-d&lt;/span&gt; 8 http://127.0.0.1:3000/json
npx autocannon &lt;span class="nt"&gt;-c&lt;/span&gt; 100 &lt;span class="nt"&gt;-d&lt;/span&gt; 8 &lt;span class="nt"&gt;-m&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; content-type&lt;span class="o"&gt;=&lt;/span&gt;application/json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"Waseem","tags":["a","b","c"],"n":7}'&lt;/span&gt; http://127.0.0.1:3000/echo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run each framework a few times, alternate the order, and use the median.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is Hono faster than Fastify?
&lt;/h3&gt;

&lt;p&gt;Not on Node.js in my test. On simple GET routes they were within 3% (Fastify 85,448 vs Hono 83,184 requests per second on one core). On a JSON POST body Fastify was 17% faster. Hono started faster (90 ms vs 158 ms) and used less memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is NestJS slower than Express?
&lt;/h3&gt;

&lt;p&gt;NestJS adds overhead on top of whatever adapter it uses. NestJS on Express did about 10% fewer requests than plain Express on GET /json. NestJS on the Fastify adapter was faster than plain Express by about 44% on the same route.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use the Fastify adapter in NestJS?
&lt;/h3&gt;

&lt;p&gt;If you don't depend on Express-only middleware, yes. In this benchmark NestJS on Fastify served 60-75% more GET requests per second than NestJS on Express, started faster (404 ms vs 486 ms) and used less memory under load.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Express 5 faster than Express 4?
&lt;/h3&gt;

&lt;p&gt;I did not benchmark Express 4 in this test. Express 5 focuses on modern behaviour (promise-aware error handling, updated path matching) rather than speed, and in this test it was still about 43% behind Fastify on GET routes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which is best for a SaaS backend in 2026?
&lt;/h3&gt;

&lt;p&gt;For a team project with auth, modules and many endpoints, NestJS on the Fastify adapter gives you structure without giving up much speed. For a small API or one that needs to run on Cloudflare Workers or Bun too, Hono is the simpler choice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://fabwaseem.com/blog/nestjs-vs-hono-vs-fastify" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>nestjs</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to Build an MCP Server in NestJS: Streamable HTTP, Guards and DI (Tested)</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Wed, 04 Nov 2026 06:45:24 +0000</pubDate>
      <link>https://dev.to/fabwaseem/how-to-build-an-mcp-server-in-nestjs-streamable-http-guards-and-di-tested-iad</link>
      <guid>https://dev.to/fabwaseem/how-to-build-an-mcp-server-in-nestjs-streamable-http-guards-and-di-tested-iad</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/nestjs-mcp-server" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To build an MCP server in NestJS, install the official MCP TypeScript SDK (&lt;code&gt;@modelcontextprotocol/server&lt;/code&gt; and &lt;code&gt;@modelcontextprotocol/node&lt;/code&gt;), build one &lt;code&gt;createMcpHandler&lt;/code&gt; inside an injectable service, and call it from a &lt;code&gt;@Post('mcp')&lt;/code&gt; controller method that takes &lt;code&gt;@Req()&lt;/code&gt; and &lt;code&gt;@Res()&lt;/code&gt;. Put a Nest guard in front for the bearer token and &lt;code&gt;Origin&lt;/code&gt; check, pass &lt;code&gt;req.body&lt;/code&gt; to the handler, and your tools can use any provider through normal dependency injection.&lt;/p&gt;

&lt;p&gt;I built exactly that on November 4, 2026 and tested it end to end: with curl, the MCP Inspector CLI, and 8 e2e tests that use the official MCP client against both protocol versions. Below is the full code, how the pieces fit, and five problems I hit that most tutorials skip, including one that turns every request into a &lt;code&gt;Parse error&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there an official NestJS MCP module?
&lt;/h2&gt;

&lt;p&gt;No. There is no &lt;code&gt;@nestjs/mcp&lt;/code&gt; package: the npm registry returned 404 for it on November 4, 2026. You have two real options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wire the official SDK yourself&lt;/strong&gt;, which is what this guide does. It came to about 180 lines across a provider, a service, a guard, a controller and two modules, and you control auth, scopes and errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the community module &lt;a href="https://github.com/rekog-labs/MCP-Nest" rel="noopener noreferrer"&gt;&lt;code&gt;@rekog/mcp-nest&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; (MIT, version 2.0.9, about 384,000 npm downloads in the week of October 27 to November 2, 2026). Its README describes it as a NestJS microservice transport strategy: tools are &lt;code&gt;@Tool()&lt;/code&gt; methods on &lt;code&gt;@McpController()&lt;/code&gt; classes, so Nest guards, pipes, interceptors and filters apply, and it serves Streamable HTTP and STDIO. It peer-depends on the same v2 SDK packages and isn't maintained by the NestJS team.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hand-wiring is worth understanding even if you end up on the module, because the gotchas below come from how Nest and the SDK share one Express request.&lt;/p&gt;

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

&lt;p&gt;Everything ran on one Linux VM (8 vCPUs) on Node.js 24.22.1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NestJS 12.3.2&lt;/strong&gt; with &lt;code&gt;@nestjs/platform-express&lt;/code&gt; (Express 5.3.0)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@modelcontextprotocol/server&lt;/code&gt; 2.4.0&lt;/strong&gt; and &lt;strong&gt;&lt;code&gt;@modelcontextprotocol/node&lt;/code&gt; 2.1.2&lt;/strong&gt;, the v2 SDK&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@modelcontextprotocol/client&lt;/code&gt; 2.4.0&lt;/strong&gt; for the e2e tests, &lt;strong&gt;MCP Inspector 2.13.0&lt;/strong&gt; for manual calls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zod 4.6.7&lt;/strong&gt;, &lt;strong&gt;TypeScript 6.0.3&lt;/strong&gt;, tests on &lt;code&gt;node:test&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The demo is a small notes API: a &lt;code&gt;NotesService&lt;/code&gt; provider, a &lt;code&gt;search_notes&lt;/code&gt; tool any token can call, and a &lt;code&gt;create_note&lt;/code&gt; tool only a write-scoped token can see. If you're new to the SDK itself, my guide on &lt;a href="https://fabwaseem.com/blog/build-mcp-server-nextjs" rel="noopener noreferrer"&gt;building an MCP server in Next.js&lt;/a&gt; covers tools, schemas and the v1 to v2 changes in more depth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Install the packages
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs &lt;span class="se"&gt;\&lt;/span&gt;
  @modelcontextprotocol/server @modelcontextprotocol/node zod
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; typescript @types/node @types/express @nestjs/testing @modelcontextprotocol/client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;NestJS 12 ships as ES modules (&lt;code&gt;"type": "module"&lt;/code&gt; in &lt;code&gt;@nestjs/core&lt;/code&gt;), so set &lt;code&gt;"type": "module"&lt;/code&gt; in your &lt;code&gt;package.json&lt;/code&gt; and use &lt;code&gt;.js&lt;/code&gt; extensions in relative imports. The &lt;code&gt;tsconfig.json&lt;/code&gt; I used:&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;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodenext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"moduleResolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodenext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES2023"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"experimentalDecorators"&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;"emitDecoratorMetadata"&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;"strict"&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;"strictPropertyInitialization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"skipLibCheck"&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;"outDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rootDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"include"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&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;h2&gt;
  
  
  Step 2: A normal Nest provider for your data
&lt;/h2&gt;

&lt;p&gt;The tools should call the same services your REST controllers already use. Here an in-memory store stands in for a Prisma or TypeORM repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/notes/notes.service.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;Injectable&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@nestjs/common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Note&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotesService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;nextId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Note&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;needle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;n&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Note&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;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextId&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;note&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;note&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;Export it from a &lt;code&gt;NotesModule&lt;/code&gt; (&lt;code&gt;providers: [NotesService], exports: [NotesService]&lt;/code&gt;) like any shared provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The MCP service (handler, tools and DI)
&lt;/h2&gt;

&lt;p&gt;This is the core. &lt;code&gt;createMcpHandler&lt;/code&gt; takes a factory that returns a fresh &lt;code&gt;McpServer&lt;/code&gt;, and the &lt;a href="https://ts.sdk.modelcontextprotocol.io/v2/serving/http" rel="noopener noreferrer"&gt;SDK's HTTP guide&lt;/a&gt; says the factory runs once per HTTP request and receives the request context, including &lt;code&gt;authInfo&lt;/code&gt;. That gives you two things in Nest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The factory is an arrow function inside an &lt;code&gt;@Injectable()&lt;/code&gt; class, so every tool handler can use &lt;code&gt;this.notes&lt;/code&gt; or any other injected provider.&lt;/li&gt;
&lt;li&gt;You can register a different set of tools per caller. A token without the write scope never sees &lt;code&gt;create_note&lt;/code&gt; in &lt;code&gt;tools/list&lt;/code&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/mcp/mcp.service.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;Injectable&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="nx"&gt;OnModuleDestroy&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@nestjs/common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createMcpHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;McpServer&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;McpRequestContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;toNodeHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/node&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="o"&gt;*&lt;/span&gt; &lt;span class="kd"&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/v4&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;NotesService&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;../notes/notes.service.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="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;McpService&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;OnModuleDestroy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;logger&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;Logger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;McpService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Built once. The factory runs once per HTTP request, so every request gets a fresh McpServer.&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMcpHandler&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;responseMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;onerror&lt;/span&gt;&lt;span class="p"&gt;:&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="k"&gt;this&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="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toNodeHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NotesService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;authInfo&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;McpRequestContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;McpServer&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;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;authInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;anonymous&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;scopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;authInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;scopes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&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;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;search_notes&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Search notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Search the caller's notes by a word in the title or body. Returns at most 20.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;q&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;200&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="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="s2"&gt;Text to match; omit to list all&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;annotations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;readOnlyHint&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;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;q&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;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;20&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;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hits&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;span class="c1"&gt;// Only tokens with the write scope ever see this tool in tools/list.&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;scopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;notes:write&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;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create_note&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Create note&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Save a new note for the caller.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;body&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;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
          &lt;span class="na"&gt;outputSchema&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;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;number&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="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="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;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&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;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="na"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;out&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;onModuleDestroy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes on the choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;toNodeHandler&lt;/code&gt;&lt;/strong&gt; from &lt;code&gt;@modelcontextprotocol/node&lt;/code&gt; adapts the fetch-style handler to Node's &lt;code&gt;(req, res, parsedBody)&lt;/code&gt;, which is what Express (and so Nest) gives you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;responseMode: "json"&lt;/code&gt;&lt;/strong&gt; answers modern requests with one JSON body. The SDK prints a console warning when you set it, because progress and log notifications sent before the result are dropped. Leave it out if your tools stream progress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;handler.close()&lt;/code&gt;&lt;/strong&gt; in &lt;code&gt;onModuleDestroy&lt;/code&gt; aborts in-flight requests when Nest shuts down. Call &lt;code&gt;app.enableShutdownHooks()&lt;/code&gt; so the hook runs on SIGTERM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The owner comes from &lt;code&gt;authInfo&lt;/code&gt;, not from tool arguments.&lt;/strong&gt; The model can't ask for someone else's notes by passing a different user ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: A guard for the bearer token and Origin
&lt;/h2&gt;

&lt;p&gt;The SDK checks no tokens and no &lt;code&gt;Origin&lt;/code&gt; header on its own: &lt;code&gt;authInfo&lt;/code&gt; is pass-through. A Nest guard is the natural place for both. The &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http" rel="noopener noreferrer"&gt;MCP transport spec&lt;/a&gt; says servers must validate &lt;code&gt;Origin&lt;/code&gt; to stop DNS rebinding, and the SDK exports &lt;code&gt;validateOriginHeader&lt;/code&gt; for that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/mcp/mcp-auth.guard.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;CanActivate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ForbiddenException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UnauthorizedException&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@nestjs/common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validateOriginHeader&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;AuthInfo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;same&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;sha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// token -&amp;gt; who it belongs to and what it may do. Load from env or a DB in a real app.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tokenTable&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;scopes&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&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;MCP_READ_TOKEN&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reader&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scopes&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;notes:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&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;MCP_WRITE_TOKEN&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;writer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scopes&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;notes:read&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;notes:write&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&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;ALLOWED_ORIGINS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MCP_ALLOWED_ORIGINS&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;localhost,127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;McpAuthGuard&lt;/span&gt; &lt;span class="k"&gt;implements&lt;/span&gt; &lt;span class="nx"&gt;CanActivate&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;canActivate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ExecutionContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;switchToHttp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getRequest&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Request&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;AuthInfo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// DNS rebinding / CSRF: a browser Origin must be on the list. No Origin (CLI, agents) passes.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateOriginHeader&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_ORIGINS&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;origin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ForbiddenException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^Bearer&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(\S&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorization&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;tokenTable&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;same&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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="nx"&gt;t&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="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;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WWW-Authenticate&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;Bearer realm="mcp"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UnauthorizedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid or missing bearer token&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scopes&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guard sets &lt;code&gt;req.auth&lt;/code&gt;, and &lt;code&gt;toNodeHandler&lt;/code&gt; reads exactly that property and hands it to your factory as &lt;code&gt;authInfo&lt;/code&gt;. That one line is the bridge between Nest's auth and the MCP server. Hashing both tokens before &lt;code&gt;timingSafeEqual&lt;/code&gt; keeps the comparison constant-time even when lengths differ, and tokens shorter than 32 characters are ignored so an empty env var can't open the endpoint. For OAuth instead of static tokens, see the &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization" rel="noopener noreferrer"&gt;MCP authorization spec&lt;/a&gt; and my notes on &lt;a href="https://fabwaseem.com/blog/mcp-security" rel="noopener noreferrer"&gt;MCP security&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: The controller
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/mcp/mcp.controller.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;All&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;Header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;HttpCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;UseGuards&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@nestjs/common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&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;Response&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;express&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;McpAuthGuard&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;./mcp-auth.guard.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;McpService&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;./mcp.service.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="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mcp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;McpController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;McpService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;UseGuards&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;McpAuthGuard&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Req&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="nd"&gt;Res&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;Response&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="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// With @Res() the SDK writes the response itself, so set headers on res directly.&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;setHeader&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-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Pass req.body: Nest's JSON parser has already read the stream.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Stateless server: no GET stream, no DELETE session.&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;All&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;HttpCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Allow&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;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;notAllowed&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;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;Method not allowed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the module and &lt;code&gt;main.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/mcp/mcp.module.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;Module&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@nestjs/common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NotesModule&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;../notes/notes.module.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;McpController&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;./mcp.controller.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;McpService&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;./mcp.service.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="nd"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;imports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;NotesModule&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;controllers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;McpController&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;McpService&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;class&lt;/span&gt; &lt;span class="nc"&gt;McpModule&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;// src/main.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reflect-metadata&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;NestFactory&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@nestjs/core&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NestExpressApplication&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@nestjs/platform-express&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;AppModule&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;./app.module.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&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;NestFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;NestExpressApplication&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;AppModule&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// Express's JSON parser defaults to 100kb; the MCP SDK itself allows 4 MiB.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useBodyParser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;4mb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enableShutdownHooks&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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;PORT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`MCP endpoint: &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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUrl&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;/mcp`&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;AppModule&lt;/code&gt; just imports &lt;code&gt;McpModule&lt;/code&gt;. Build with &lt;code&gt;tsc&lt;/code&gt;, start with two tokens, and the endpoint is &lt;code&gt;http://127.0.0.1:3000/mcp&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Test it (curl, Inspector, e2e)
&lt;/h2&gt;

&lt;p&gt;First the cheap checks with curl. Against my running server:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;POST /mcp&lt;/code&gt; with no token&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;401&lt;/code&gt;, &lt;code&gt;WWW-Authenticate: Bearer realm="mcp"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;POST /mcp&lt;/code&gt; with &lt;code&gt;Origin: https://evil.example&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;403&lt;/code&gt; &lt;code&gt;Invalid Origin: evil.example&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /mcp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;405&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tools/list&lt;/code&gt; with the read token&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;search_notes&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tools/list&lt;/code&gt; with the write token&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;search_notes&lt;/code&gt;, &lt;code&gt;create_note&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then a real tool call with the &lt;a href="https://github.com/modelcontextprotocol/inspector" rel="noopener noreferrer"&gt;MCP Inspector&lt;/a&gt; CLI. It takes the bearer token through &lt;code&gt;--header&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @modelcontextprotocol/inspector &lt;span class="nt"&gt;--cli&lt;/span&gt; http://127.0.0.1:3000/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transport&lt;/span&gt; http &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$MCP_WRITE_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--method&lt;/span&gt; tools/call &lt;span class="nt"&gt;--tool-name&lt;/span&gt; create_note &lt;span class="nt"&gt;--tool-arg&lt;/span&gt; &lt;span class="nv"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Hello &lt;span class="nt"&gt;--tool-arg&lt;/span&gt; &lt;span class="nv"&gt;body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;From-inspector
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It printed &lt;code&gt;"structuredContent": { "id": 1, "title": "Hello" }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For CI, boot the real Nest app on a random port with &lt;code&gt;@nestjs/testing&lt;/code&gt; and drive it with the official client. This is a trimmed version of my test file; the full one has 8 tests and all passed:&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;// test/mcp.e2e.test.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reflect-metadata&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;after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;before&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="s2"&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;randomBytes&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;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="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="s2"&gt;@nestjs/testing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;INestApplication&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@nestjs/common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;StreamableHTTPClientTransport&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MCP_READ_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MCP_WRITE_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AppModule&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../src/app.module.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;INestApplication&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;url&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;connect&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;legacy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e2e&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;versionNegotiation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StreamableHTTPClientTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;requestInit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="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="s2"&gt;NestJS MCP endpoint&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="nf"&gt;before&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mod&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;Test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTestingModule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;imports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;AppModule&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createNestApplication&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUrl&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;/mcp`&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[::1]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;after&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&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;legacy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="kd"&gt;as const&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="s2"&gt;`scopes the tool list per token (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; handshake)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&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;connect&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;MCP_READ_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&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;writer&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;connect&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;MCP_WRITE_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&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;deepEqual&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;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTools&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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;search_notes&lt;/span&gt;&lt;span class="dl"&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;deepEqual&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;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTools&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sort&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;create_note&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;search_notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="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="s2"&gt;runs tools through Nest DI and keeps data per caller&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;writer&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;connect&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;MCP_WRITE_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&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;connect&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;MCP_READ_TOKEN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;created&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;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create_note&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Deploy notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rotate MCP tokens&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;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="mi"&gt;1&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Deploy notes&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;theirs&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;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;callTool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;search_notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rotate&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;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;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;theirs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&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;writer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;auto&lt;/code&gt; client negotiated protocol &lt;code&gt;2026-07-28&lt;/code&gt; (the modern era) and the &lt;code&gt;legacy&lt;/code&gt; client &lt;code&gt;2025-11-25&lt;/code&gt;, and the same factory served both. If you want the testing side in more depth (in-memory transport, Vitest, snapshots of &lt;code&gt;tools/list&lt;/code&gt;), I wrote a separate guide on &lt;a href="https://fabwaseem.com/blog/test-mcp-server" rel="noopener noreferrer"&gt;how to test an MCP server&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things that broke (and the fixes)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Forgetting &lt;code&gt;req.body&lt;/code&gt; gives &lt;code&gt;-32700 Parse error&lt;/code&gt;.&lt;/strong&gt; Nest's JSON body parser reads the request stream before your controller runs. When I called &lt;code&gt;this.mcp.node(req, res)&lt;/code&gt; without the third argument, every request got &lt;code&gt;400&lt;/code&gt; with &lt;code&gt;{"code":-32700,"message":"Parse error: Invalid JSON"}&lt;/code&gt;, because the SDK found an empty stream. Always pass &lt;code&gt;req.body&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Nest's 100kb body limit answers before the SDK does.&lt;/strong&gt; A 200 KB &lt;code&gt;tools/call&lt;/code&gt; got &lt;code&gt;413 request entity too large&lt;/code&gt; from Express, not from the MCP server, so the client sees a plain HTTP error instead of a JSON-RPC one. The SDK's own default limit is 4 MiB (&lt;code&gt;maxRequestBodySize&lt;/code&gt;). If your tools accept documents, raise Nest's limit with &lt;code&gt;app.useBodyParser("json", { limit: "4mb" })&lt;/code&gt;. With that change, the same oversized call reached the SDK and came back as a normal tool error from Zod (&lt;code&gt;q: Too big: expected string to have &amp;lt;=200 characters&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;responseMode: "json"&lt;/code&gt; only shapes modern traffic.&lt;/strong&gt; I logged the responses from both client modes. The &lt;code&gt;2026-07-28&lt;/code&gt; client made two POSTs and got &lt;code&gt;application/json&lt;/code&gt; both times. The &lt;code&gt;2025-11-25&lt;/code&gt; client got &lt;code&gt;text/event-stream&lt;/code&gt; for its requests even with &lt;code&gt;responseMode: "json"&lt;/code&gt;, plus a &lt;code&gt;202&lt;/code&gt; for the &lt;code&gt;initialized&lt;/code&gt; notification and a &lt;code&gt;GET&lt;/code&gt; that my controller answered with &lt;code&gt;405&lt;/code&gt;. That's documented in the option's type comments: legacy traffic is served by a stateless fallback transport. Don't put a proxy in front that breaks SSE, or old clients fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;@Header()&lt;/code&gt; is the wrong tool on the MCP route.&lt;/strong&gt; With &lt;code&gt;@Res()&lt;/code&gt;, the SDK calls &lt;code&gt;writeHead&lt;/code&gt; itself. I set &lt;code&gt;Cache-Control: no-store&lt;/code&gt; on &lt;code&gt;res&lt;/code&gt; before handing it over: modern JSON responses kept it, but on legacy SSE responses the SDK's own &lt;code&gt;Cache-Control: no-cache, no-transform&lt;/code&gt; replaced it. Both stop caching, so it's fine, but set headers on &lt;code&gt;res&lt;/code&gt;, not with decorators.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. A hidden tool is a protocol error, not a tool error.&lt;/strong&gt; When the read token called &lt;code&gt;create_note&lt;/code&gt;, the client threw &lt;code&gt;Tool create_note not found&lt;/code&gt; rather than returning an &lt;code&gt;isError&lt;/code&gt; result. That's what you want for scoping: the tool doesn't exist for that caller. Bad arguments are different: an empty &lt;code&gt;title&lt;/code&gt; came back as a normal result with &lt;code&gt;isError: true&lt;/code&gt; and &lt;code&gt;Input validation error: ... title: Too small&lt;/code&gt;, so the model can fix the call and retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  How fast is a per-request MCP server in NestJS?
&lt;/h2&gt;

&lt;p&gt;Fast enough to ignore. I made 500 sequential &lt;code&gt;search_notes&lt;/code&gt; calls from one connected client after 50 warm-up calls, three runs per protocol mode, on localhost:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p95&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;2026-07-28&lt;/code&gt; (modern)&lt;/td&gt;
&lt;td&gt;1.36 to 1.69 ms&lt;/td&gt;
&lt;td&gt;1.94 to 2.73 ms&lt;/td&gt;
&lt;td&gt;2.97 to 5.56 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;2025-11-25&lt;/code&gt; (legacy)&lt;/td&gt;
&lt;td&gt;1.39 to 1.71 ms&lt;/td&gt;
&lt;td&gt;2.05 to 2.97 ms&lt;/td&gt;
&lt;td&gt;2.94 to 5.27 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ranges are the lowest and highest of the three runs. That's the whole round trip: Nest routing, the guard, a fresh &lt;code&gt;McpServer&lt;/code&gt; from the factory, Zod validation, the tool and the response. Your database call will dominate. A fresh server per request is how the SDK's HTTP guide says &lt;code&gt;createMcpHandler&lt;/code&gt; works, and I covered the trade-offs of that stateless design in &lt;a href="https://fabwaseem.com/blog/mcp-server-stateless" rel="noopener noreferrer"&gt;stateless MCP servers&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you use @rekog/mcp-nest instead?
&lt;/h2&gt;

&lt;p&gt;Use the community module if you want decorators: tools declared as &lt;code&gt;@Tool()&lt;/code&gt; methods on &lt;code&gt;@McpController()&lt;/code&gt; classes, with Nest pipes and interceptors around them, and STDIO as well as HTTP. Hand-wire the SDK, as above, if you want per-token tool lists, your own guard and error format, or fewer dependencies between you and SDK updates. The SDK's v2 line only went stable in July 2026 (&lt;code&gt;@modelcontextprotocol/server&lt;/code&gt; 2.0.0), so expect changes; with the hand-wired version I upgrade two packages and rerun my e2e tests. Either way, connect clients the same way: my guide on &lt;a href="https://fabwaseem.com/blog/add-remote-mcp-server" rel="noopener noreferrer"&gt;adding a remote MCP server to Cursor, Claude Code and VS Code&lt;/a&gt; shows the config for each.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is there an official NestJS MCP package?
&lt;/h3&gt;

&lt;p&gt;No. As of November 4, 2026 the npm registry has no @nestjs/mcp package. You can wire the official MCP TypeScript SDK into a Nest controller yourself, or use the community module @rekog/mcp-nest (MIT, version 2.0.9), which is not maintained by the NestJS team.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use NestJS dependency injection inside MCP tools?
&lt;/h3&gt;

&lt;p&gt;Yes. Create the handler with createMcpHandler inside an @Injectable() service and register tools in a factory method on that class. Tool handlers then call injected providers through this, like any other Nest service. The factory runs once per HTTP request.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I add authentication to a NestJS MCP server?
&lt;/h3&gt;

&lt;p&gt;Use a Nest guard on the POST /mcp route. Verify the bearer token, reject bad Origin headers with 403, and set req.auth to an object with token, clientId and scopes. The @modelcontextprotocol/node adapter passes req.auth to your server factory as authInfo, so you can scope data and tools per caller.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my NestJS MCP endpoint return Parse error -32700?
&lt;/h3&gt;

&lt;p&gt;Nest's JSON body parser has already consumed the request stream. Pass the parsed body as the third argument: toNodeHandler(handler)(req, res, req.body). Without it the SDK reads an empty stream and answers 400 with Parse error: Invalid JSON.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does a NestJS MCP server work with older MCP clients?
&lt;/h3&gt;

&lt;p&gt;Yes. createMcpHandler serves 2025-era clients through a stateless fallback by default. In my tests one endpoint answered a 2026-07-28 client with JSON and a 2025-11-25 client over SSE. Set legacy: "reject" if you only want the modern protocol.&lt;/p&gt;




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

</description>
      <category>nestjs</category>
      <category>mcp</category>
      <category>ai</category>
      <category>typescript</category>
    </item>
    <item>
      <title>pnpm vs npm Security in 2026: Install Scripts and Release Age, Tested</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Wed, 04 Nov 2026 06:44:58 +0000</pubDate>
      <link>https://dev.to/fabwaseem/pnpm-vs-npm-security-in-2026-install-scripts-and-release-age-tested-14j1</link>
      <guid>https://dev.to/fabwaseem/pnpm-vs-npm-security-in-2026-install-scripts-and-release-age-tested-14j1</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/pnpm-vs-npm-security" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most npm malware runs the moment you install it, through a &lt;code&gt;postinstall&lt;/code&gt; script, and it usually ships as a fresh patch release that gets pulled within hours or days. So the two package-manager settings that matter most for supply chain security are: &lt;strong&gt;does it run dependency install scripts by default&lt;/strong&gt;, and &lt;strong&gt;will it install a version that was published an hour ago&lt;/strong&gt;. I tested both on &lt;strong&gt;pnpm vs npm&lt;/strong&gt; tonight with a harmless canary package and a real package that had just shipped a release. The defaults are not what most people assume, and they differ between npm 11 and npm 12.&lt;/p&gt;

&lt;h2&gt;
  
  
  pnpm vs npm security: results with default settings
&lt;/h2&gt;

&lt;p&gt;Tested November 3, 2026 on Linux with Node 26.13.0.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;npm 11.20.0 (bundled with Node 26.13)&lt;/th&gt;
&lt;th&gt;npm 12.2.1 (&lt;code&gt;latest&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;pnpm 12.16.1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dependency &lt;code&gt;postinstall&lt;/code&gt; script&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;ran&lt;/strong&gt;, with a warning&lt;/td&gt;
&lt;td&gt;blocked, with a warning&lt;/td&gt;
&lt;td&gt;blocked, install exits with an error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Installs a version published 17 hours ago&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no (waits 1 day)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approve a package's scripts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm approve-scripts &amp;lt;pkg&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm approve-scripts &amp;lt;pkg&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;allowBuilds&lt;/code&gt; in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Release-age setting&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;min-release-age&lt;/code&gt; (days, off by default)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;min-release-age&lt;/code&gt; (days, off by default)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;minimumReleaseAge&lt;/code&gt; (minutes, default 1440)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Node 24.22.2 bundles npm 11.19.1, which has the same script settings as 11.20. If you use the npm that came with Node, you're on npm 11.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 1: does the install script run?
&lt;/h2&gt;

&lt;p&gt;I made a local package whose only job is to write a file when its &lt;code&gt;postinstall&lt;/code&gt; runs:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"install-canary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&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;"postinstall"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node -e &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;require('fs').writeFileSync('/tmp/canary','ran')&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I packed it with &lt;code&gt;npm pack&lt;/code&gt; and installed the tarball into an empty project with each tool, deleting &lt;code&gt;/tmp/canary&lt;/code&gt; before each run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;npm 11.20.0&lt;/strong&gt; printed a warning and then ran the script anyway. &lt;code&gt;/tmp/canary&lt;/code&gt; existed afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn install-scripts   install-canary@1.0.0 (postinstall: node -e "...")
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve &amp;lt;pkg&amp;gt;` to allow.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;npm 12.2.1&lt;/strong&gt; printed a similar warning, but worded as "had install scripts &lt;strong&gt;blocked&lt;/strong&gt;", and the canary file was not created.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pnpm 12.16.1&lt;/strong&gt; didn't run the script and exited with code 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;× adding a new package
╰─▶ Ignored build scripts: install-canary@file:../canary/install-canary-1.0.0.tgz
help: Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The packages were still written to &lt;code&gt;node_modules&lt;/code&gt;, but the failing exit code means CI stops instead of quietly continuing. pnpm's docs call this &lt;code&gt;strictDepBuilds&lt;/code&gt;, and it's on by default.&lt;/p&gt;

&lt;p&gt;I got the same pattern with &lt;code&gt;esbuild@0.28.2&lt;/code&gt;, a real package with a legitimate &lt;code&gt;postinstall&lt;/code&gt;: npm 11 ran it with a warning, npm 12 blocked it, and pnpm blocked it and failed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Allowing the scripts you trust
&lt;/h3&gt;

&lt;p&gt;With npm (11.19+ and 12), approve per package. The approval is pinned to the exact version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm approve-scripts esbuild
node &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"JSON.stringify(require('./package.json').allowScripts)"&lt;/span&gt;
&lt;span class="c"&gt;# {"esbuild@0.28.2":true}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the approval is pinned, a new esbuild release will be flagged again, which is exactly when you want to look.&lt;/p&gt;

&lt;p&gt;With pnpm, list trusted packages in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;allowBuilds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;esbuild&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that file in place, &lt;code&gt;pnpm add esbuild@0.28.2&lt;/code&gt; finished with exit code 0. &lt;code&gt;pnpm approve-builds&lt;/code&gt; gives you an interactive picker for the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: will it install a brand-new release?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;ai&lt;/code&gt; package published 7.0.162 at 01:19 UTC on November 3, about 17 hours before I ran this. The previous releases were 7.0.161 and 7.0.160 on November 2, and 7.0.159 on October 30.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Installed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;npm i ai&lt;/code&gt; (npm 11.20.0)&lt;/td&gt;
&lt;td&gt;7.0.162&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;npm i ai&lt;/code&gt; (npm 12.2.1)&lt;/td&gt;
&lt;td&gt;7.0.162&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;npm i ai --min-release-age=1&lt;/code&gt; (npm 11.20.0)&lt;/td&gt;
&lt;td&gt;7.0.159&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pnpm add ai&lt;/code&gt; (pnpm 12.16.1, defaults)&lt;/td&gt;
&lt;td&gt;7.0.159&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both npm versions installed the newest release by default. pnpm skipped everything younger than 24 hours because &lt;code&gt;minimumReleaseAge&lt;/code&gt; defaults to 1440 minutes, as described in pnpm's &lt;a href="https://pnpm.io/supply-chain-security" rel="noopener noreferrer"&gt;supply chain security docs&lt;/a&gt;. npm's &lt;a href="https://docs.npmjs.com/cli/v11/using-npm/config/" rel="noopener noreferrer"&gt;&lt;code&gt;min-release-age&lt;/code&gt;&lt;/a&gt; does the same once you set it, but it's measured in days and is off by default.&lt;/p&gt;

&lt;p&gt;Note that the cooldown cuts both ways. A real security fix published today also waits. Both tools let you exempt packages: &lt;code&gt;min-release-age-exclude&lt;/code&gt; in npm, &lt;code&gt;minimumReleaseAgeExclude&lt;/code&gt; in pnpm. With &lt;code&gt;minimumReleaseAgeExclude: [ai]&lt;/code&gt; in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, pnpm installed 7.0.162 straight away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recommended settings for each
&lt;/h2&gt;

&lt;h3&gt;
  
  
  npm
&lt;/h3&gt;

&lt;p&gt;In the project's &lt;code&gt;.npmrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;min-release-age&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;strict-allow-scripts&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then approve the few packages that genuinely need install scripts with &lt;code&gt;npm approve-scripts &amp;lt;pkg&amp;gt;&lt;/code&gt;, and commit the &lt;code&gt;allowScripts&lt;/code&gt; field in &lt;code&gt;package.json&lt;/code&gt;. If you're still on npm 11, upgrading to npm 12 gets you blocking by default: &lt;code&gt;npm i -g npm@12&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  pnpm
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;minimumReleaseAge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4320&lt;/span&gt;        &lt;span class="c1"&gt;# 3 days, in minutes&lt;/span&gt;
&lt;span class="na"&gt;trustPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;no-downgrade&lt;/span&gt;      &lt;span class="c1"&gt;# refuse a version with weaker publish provenance than earlier ones&lt;/span&gt;
&lt;span class="na"&gt;allowBuilds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;esbuild&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;sharp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Script blocking, &lt;code&gt;strictDepBuilds&lt;/code&gt; and &lt;code&gt;blockExoticSubdeps&lt;/code&gt; (no git or tarball URLs deep in the tree) are already on by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  Both
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commit the lockfile and install with &lt;code&gt;npm ci&lt;/code&gt; / &lt;code&gt;pnpm install --frozen-lockfile&lt;/code&gt; in CI.&lt;/strong&gt; The release-age window matters when versions get resolved, which is when the lockfile changes, so review lockfile diffs in pull requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't run installs with secrets in the environment.&lt;/strong&gt; If a script does slip through, it can read whatever env vars the install step has.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build Docker images from the lockfile.&lt;/strong&gt; My &lt;a href="https://fabwaseem.com/blog/nextjs-dockerfile" rel="noopener noreferrer"&gt;Next.js Dockerfile&lt;/a&gt; installs from the lockfile in a separate deps stage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you write server code that fetches user-supplied URLs, &lt;a href="https://fabwaseem.com/blog/ssrf-protection-nodejs" rel="noopener noreferrer"&gt;SSRF protection in Node.js&lt;/a&gt; covers the other half of keeping a Node app safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, is pnpm more secure than npm?
&lt;/h2&gt;

&lt;p&gt;With default settings, yes, in both tests: pnpm blocked the install script and failed the install, and it refused a 17-hour-old release. npm 12 now blocks install scripts by default too, but still installs brand-new releases unless you set &lt;code&gt;min-release-age&lt;/code&gt;. npm 11, which is what ships with Node 24 and Node 26 today, only warns about install scripts. Configured properly, npm 11.19+ and npm 12 can match pnpm on both points. The difference is what you get if you never touch the config.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does pnpm run postinstall scripts?
&lt;/h3&gt;

&lt;p&gt;Not for dependencies, unless you allow them. pnpm 12.16.1 skipped my canary's &lt;code&gt;postinstall&lt;/code&gt; and exited with &lt;code&gt;Ignored build scripts&lt;/code&gt;. Allow trusted packages with &lt;code&gt;allowBuilds&lt;/code&gt; in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; or by running &lt;code&gt;pnpm approve-builds&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does npm block install scripts by default?
&lt;/h3&gt;

&lt;p&gt;npm 12 does: version 12.2.1 blocked my canary's &lt;code&gt;postinstall&lt;/code&gt;. npm 11.20.0, the version bundled with Node 26.13.0, only printed a warning and ran the script. Set &lt;code&gt;strict-allow-scripts=true&lt;/code&gt; or upgrade to npm 12.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is minimumReleaseAge in pnpm?
&lt;/h3&gt;

&lt;p&gt;It's the number of minutes a version must have been published before pnpm will install it. It defaults to 1440 (one day). In my test, pnpm installed &lt;code&gt;ai@7.0.159&lt;/code&gt; instead of the 17-hour-old 7.0.162.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is min-release-age in npm?
&lt;/h3&gt;

&lt;p&gt;An npm config option, measured in days, that makes npm ignore versions published more recently than that. It's off by default. &lt;code&gt;npm i ai --min-release-age=1&lt;/code&gt; installed 7.0.159 instead of the 17-hour-old 7.0.162.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is pnpm safer than npm?
&lt;/h3&gt;

&lt;p&gt;With default settings, pnpm blocked more in my tests: it refused to run dependency install scripts and refused a release under 24 hours old. npm can be configured to do both with &lt;code&gt;strict-allow-scripts&lt;/code&gt; and &lt;code&gt;min-release-age&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://fabwaseem.com/blog/pnpm-vs-npm-security" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>npm</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Next.js Middleware to Proxy: How to Migrate middleware.ts to proxy.ts in Next.js 16</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Tue, 03 Nov 2026 18:22:35 +0000</pubDate>
      <link>https://dev.to/fabwaseem/nextjs-middleware-to-proxy-how-to-migrate-middlewarets-to-proxyts-in-nextjs-16-1n35</link>
      <guid>https://dev.to/fabwaseem/nextjs-middleware-to-proxy-how-to-migrate-middlewarets-to-proxyts-in-nextjs-16-1n35</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/nextjs-middleware-to-proxy" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In Next.js 16, &lt;code&gt;middleware.ts&lt;/code&gt; is deprecated and renamed to &lt;code&gt;proxy.ts&lt;/code&gt;. Your existing middleware still runs, but every build now prints a deprecation warning, and a few things behave differently: the function name changes, Proxy always runs on the Node.js runtime, and having both files in a project fails the build. This guide covers the &lt;strong&gt;Next.js middleware to proxy&lt;/strong&gt; migration step by step, with the exact warnings and errors I got from Next.js 16.4.5 on a small test app, so you know what to expect before you run the codemod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next.js middleware vs proxy: what actually changed
&lt;/h2&gt;

&lt;p&gt;The behavior is the same feature under a new name. From the &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/proxy" rel="noopener noreferrer"&gt;Next.js proxy docs&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;middleware.ts&lt;/code&gt; (Next.js 12–15)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;proxy.ts&lt;/code&gt; (Next.js 16+)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;File name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;middleware.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;proxy.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exported function&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;middleware&lt;/code&gt; (or default)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;proxy&lt;/code&gt; (or default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;config.matcher&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NextRequest&lt;/code&gt; / &lt;code&gt;NextResponse&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime&lt;/td&gt;
&lt;td&gt;Edge by default, Node.js opt-in since 15.5&lt;/td&gt;
&lt;td&gt;Node.js only; &lt;code&gt;runtime&lt;/code&gt; config throws&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status in 16.x&lt;/td&gt;
&lt;td&gt;deprecated, still runs with a warning&lt;/td&gt;
&lt;td&gt;recommended&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Next.js team says they renamed it because "middleware" was getting confused with Express-style middleware and encouraged using it for too much. "Proxy" describes what it is: a layer in front of your routes that can redirect, rewrite, set headers or return a response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: see the deprecation warning
&lt;/h2&gt;

&lt;p&gt;I started with a typical auth-redirect middleware on Next.js 16.4.5:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;next&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;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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&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;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="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="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&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;&lt;code&gt;next build&lt;/code&gt; succeeds, but prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.

  To migrate automatically, run:
  npx @next/codemod@canary middleware-to-proxy .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route table already labels it &lt;code&gt;ƒ Proxy (Middleware)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: run the middleware-to-proxy codemod
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;p&gt;On my test app the codemod renamed &lt;code&gt;middleware.ts&lt;/code&gt; to &lt;code&gt;proxy.ts&lt;/code&gt; and the exported function from &lt;code&gt;middleware&lt;/code&gt; to &lt;code&gt;proxy&lt;/code&gt;. Nothing else changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- // middleware.ts
- export function middleware(request: NextRequest) {
&lt;/span&gt;&lt;span class="gi"&gt;+ // proxy.ts
+ export function proxy(request: NextRequest) {
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;next build&lt;/code&gt; ran with no deprecation warning. I checked it with &lt;code&gt;next start&lt;/code&gt;: &lt;code&gt;/dashboard&lt;/code&gt; without a cookie returned a &lt;code&gt;307&lt;/code&gt; to &lt;code&gt;/login?next=%2Fdashboard&lt;/code&gt;, and with &lt;code&gt;session=waseem&lt;/code&gt; the page rendered the &lt;code&gt;x-user&lt;/code&gt; header the proxy set.&lt;/p&gt;

&lt;p&gt;Commit the codemod change on its own so the diff stays easy to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: migrate by hand (if you can't run the codemod)
&lt;/h2&gt;

&lt;p&gt;It's two edits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rename &lt;code&gt;middleware.ts&lt;/code&gt; to &lt;code&gt;proxy.ts&lt;/code&gt;, in the same place (project root, or &lt;code&gt;src/&lt;/code&gt; if you use it).&lt;/li&gt;
&lt;li&gt;Rename the exported function to &lt;code&gt;proxy&lt;/code&gt;, or use a default export.
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextProxy&lt;/span&gt; &lt;span class="o"&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;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="c1"&gt;// same logic as before&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;NextProxy&lt;/code&gt; type infers &lt;code&gt;request&lt;/code&gt; as &lt;code&gt;NextRequest&lt;/code&gt; and &lt;code&gt;event&lt;/code&gt; as &lt;code&gt;NextFetchEvent&lt;/code&gt;, so &lt;code&gt;event.waitUntil()&lt;/code&gt; for background logging still works. If you customized &lt;code&gt;pageExtensions&lt;/code&gt;, the file name follows it, for example &lt;code&gt;proxy.page.ts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Errors you may hit during the migration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "Both middleware file and proxy file are detected"
&lt;/h3&gt;

&lt;p&gt;If the old file is still there after you create &lt;code&gt;proxy.ts&lt;/code&gt;, the build fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Both middleware file "./middleware.ts" and proxy file "./proxy.ts" are detected.
Please use "./proxy.ts" only.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Delete &lt;code&gt;middleware.ts&lt;/code&gt;. This one bites when a merge brings the old file back from another branch.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Proxy does not support Edge runtime"
&lt;/h3&gt;

&lt;p&gt;Proxy always runs on Node.js. If your old middleware had &lt;code&gt;runtime: 'edge'&lt;/code&gt; in its config and you carry it over, the build fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Next.js can't recognize the exported `config` field in route.
Proxy does not support Edge runtime.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove &lt;code&gt;runtime&lt;/code&gt; from the config. For context, keeping &lt;code&gt;middleware.ts&lt;/code&gt; with &lt;code&gt;runtime: 'edge'&lt;/code&gt; on 16.4.5 also failed, asking for &lt;code&gt;'experimental-edge'&lt;/code&gt;, and that value builds but warns that the Edge Runtime is deprecated. Either way, the direction is Node.js.&lt;/p&gt;

&lt;p&gt;Running on Node.js is mostly good news: you can use Node APIs and regular npm packages (database clients, &lt;code&gt;jose&lt;/code&gt;, &lt;code&gt;crypto&lt;/code&gt;) in Proxy. The cost is that you can't rely on Edge-only behavior, so if you self-host behind your own server this changes nothing, and on platforms that ran middleware at the edge you should check where Proxy runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What doesn't change
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Matchers.&lt;/strong&gt; &lt;code&gt;config.matcher&lt;/code&gt; strings, arrays, regex and &lt;code&gt;has&lt;/code&gt; / &lt;code&gt;missing&lt;/code&gt; conditions work the same. Matchers must still be constants.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution order.&lt;/strong&gt; Proxy runs after &lt;code&gt;headers&lt;/code&gt; and &lt;code&gt;redirects&lt;/code&gt; from &lt;code&gt;next.config.js&lt;/code&gt; and before filesystem routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request header pattern.&lt;/strong&gt; Use &lt;code&gt;NextResponse.next({ request: { headers } })&lt;/code&gt; to pass data to your app. &lt;code&gt;NextResponse.next({ headers })&lt;/code&gt; sends them to the browser instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config flags.&lt;/strong&gt; &lt;code&gt;skipMiddlewareUrlNormalize&lt;/code&gt; is now &lt;code&gt;skipProxyUrlNormalize&lt;/code&gt;. &lt;code&gt;skipTrailingSlashRedirect&lt;/code&gt; is unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing helpers.&lt;/strong&gt; The current docs show &lt;code&gt;unstable_doesProxyMatch&lt;/code&gt;, but the 16.4.5 package I installed still only exports &lt;code&gt;unstable_doesMiddlewareMatch&lt;/code&gt; from &lt;code&gt;next/experimental/testing/server&lt;/code&gt;, so check your installed version before renaming it in tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Don't use proxy.ts as your only auth check
&lt;/h2&gt;

&lt;p&gt;The Next.js docs point out that Server Functions are handled as POST requests to the page that uses them, so a matcher that excludes a path also skips Server Function calls on that path. Moving a Server Function or changing a matcher can silently remove Proxy coverage. Treat Proxy as the fast redirect for signed-out users, and check the session again inside each Server Action, Route Handler and data function.&lt;/p&gt;

&lt;p&gt;I go through that pattern with a real auth library in &lt;a href="https://fabwaseem.com/blog/better-auth-nextjs" rel="noopener noreferrer"&gt;Better Auth with Next.js&lt;/a&gt;, and the October Next.js patches in &lt;a href="https://fabwaseem.com/blog/nextjs-security-update-october-2026" rel="noopener noreferrer"&gt;this security update post&lt;/a&gt; are a good reminder of why defense in depth matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Run &lt;code&gt;npx @next/codemod@canary middleware-to-proxy .&lt;/code&gt; or rename the file and function by hand&lt;/li&gt;
&lt;li&gt;[ ] Delete any leftover &lt;code&gt;middleware.ts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Remove &lt;code&gt;runtime: 'edge'&lt;/code&gt; from the Proxy config&lt;/li&gt;
&lt;li&gt;[ ] Rename &lt;code&gt;skipMiddlewareUrlNormalize&lt;/code&gt; to &lt;code&gt;skipProxyUrlNormalize&lt;/code&gt; in &lt;code&gt;next.config&lt;/code&gt; (the old name is marked &lt;code&gt;@deprecated&lt;/code&gt; in 16.4.5's types)&lt;/li&gt;
&lt;li&gt;[ ] Re-run &lt;code&gt;next build&lt;/code&gt; and confirm the warning is gone and the route table shows Proxy&lt;/li&gt;
&lt;li&gt;[ ] Re-check auth inside Server Actions and Route Handlers, not only in Proxy&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  Is Next.js middleware deprecated?
&lt;/h3&gt;

&lt;p&gt;Yes. In Next.js 16.0.0 the &lt;code&gt;middleware&lt;/code&gt; file convention was deprecated and renamed to &lt;code&gt;proxy&lt;/code&gt;. On 16.4.5, &lt;code&gt;middleware.ts&lt;/code&gt; still runs, but &lt;code&gt;next build&lt;/code&gt; prints a deprecation warning that points to the codemod.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between middleware.ts and proxy.ts in Next.js?
&lt;/h3&gt;

&lt;p&gt;The file name and the exported function name. The API (&lt;code&gt;NextRequest&lt;/code&gt;, &lt;code&gt;NextResponse&lt;/code&gt;, &lt;code&gt;config.matcher&lt;/code&gt;) is the same. The one behavior change is that Proxy always runs on the Node.js runtime and setting &lt;code&gt;runtime&lt;/code&gt; in its config is an error.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I migrate from middleware to proxy in Next.js 16?
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;npx @next/codemod@canary middleware-to-proxy .&lt;/code&gt;. It renames &lt;code&gt;middleware.ts&lt;/code&gt; to &lt;code&gt;proxy.ts&lt;/code&gt; and the &lt;code&gt;middleware&lt;/code&gt; function to &lt;code&gt;proxy&lt;/code&gt;. Then delete any leftover &lt;code&gt;middleware.ts&lt;/code&gt;, remove &lt;code&gt;runtime: 'edge'&lt;/code&gt; if present, and rebuild.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I keep both middleware.ts and proxy.ts?
&lt;/h3&gt;

&lt;p&gt;No. On Next.js 16.4.5 the build fails with "Both middleware file and proxy file are detected. Please use ./proxy.ts only."&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Next.js proxy run on the Edge runtime?
&lt;/h3&gt;

&lt;p&gt;No. Proxy uses the Node.js runtime, and the build fails with "Proxy does not support Edge runtime" if you set &lt;code&gt;runtime: 'edge'&lt;/code&gt; in its config.&lt;/p&gt;




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

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Node 24 vs Node 26: Benchmarks, Breaking Changes and Should You Upgrade (2026)</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Tue, 03 Nov 2026 18:22:32 +0000</pubDate>
      <link>https://dev.to/fabwaseem/node-24-vs-node-26-benchmarks-breaking-changes-and-should-you-upgrade-2026-5cie</link>
      <guid>https://dev.to/fabwaseem/node-24-vs-node-26-benchmarks-breaking-changes-and-should-you-upgrade-2026-5cie</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/node-24-vs-node-26" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Node.js 26 became the Active LTS line on October 28, 2026 with v26.13.0 "Lithium", and Node 24 moved to Maintenance LTS on October 20. If you're deciding between &lt;strong&gt;Node 24 vs Node 26&lt;/strong&gt; for a production app, this post covers what changes, what breaks, and how the two compare on a few benchmarks I ran on the same machine tonight. The short version: Node 26 was faster at JSON serialization, async-heavy code and a plain &lt;code&gt;node:http&lt;/code&gt; server, but slower in two places you might not expect, &lt;code&gt;fetch()&lt;/code&gt; and inserting a million keys into a &lt;code&gt;Map&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Node 24 vs Node 26 at a glance
&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;Node 24 "Krypton"&lt;/th&gt;
&lt;th&gt;Node 26 "Lithium"&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Version tested&lt;/td&gt;
&lt;td&gt;v24.22.2&lt;/td&gt;
&lt;td&gt;v26.13.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status (Nov 2026)&lt;/td&gt;
&lt;td&gt;Maintenance LTS since Oct 20, 2026&lt;/td&gt;
&lt;td&gt;Active LTS since Oct 28, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End of life&lt;/td&gt;
&lt;td&gt;April 30, 2028&lt;/td&gt;
&lt;td&gt;April 30, 2029&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V8&lt;/td&gt;
&lt;td&gt;13.6&lt;/td&gt;
&lt;td&gt;14.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undici (powers &lt;code&gt;fetch&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;7.30.1&lt;/td&gt;
&lt;td&gt;8.12.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NODE_MODULE_VERSION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;137&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundled npm&lt;/td&gt;
&lt;td&gt;11.19.1&lt;/td&gt;
&lt;td&gt;11.20.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Temporal&lt;/code&gt; global&lt;/td&gt;
&lt;td&gt;not available&lt;/td&gt;
&lt;td&gt;on by default&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Dates come from the &lt;a href="https://github.com/nodejs/Release/blob/main/schedule.json" rel="noopener noreferrer"&gt;Node.js release schedule&lt;/a&gt;. Versions are what &lt;code&gt;process.versions&lt;/code&gt; printed on each binary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's new in Node 26
&lt;/h2&gt;

&lt;p&gt;From the &lt;a href="https://nodejs.org/en/blog/release/v26.0.0" rel="noopener noreferrer"&gt;Node 26.0.0 release notes&lt;/a&gt; and checked on v26.13.0:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Temporal is enabled by default.&lt;/strong&gt; &lt;code&gt;Temporal.Now.plainDateISO('Asia/Karachi')&lt;/code&gt; works with no flag. On Node 24, &lt;code&gt;Temporal&lt;/code&gt; is undefined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;V8 14.6&lt;/strong&gt; brings &lt;code&gt;Map.prototype.getOrInsert()&lt;/code&gt; / &lt;code&gt;getOrInsertComputed()&lt;/code&gt; (the upsert proposal) and &lt;code&gt;Iterator.concat()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undici 8&lt;/strong&gt;, so the global &lt;code&gt;fetch()&lt;/code&gt; is a different major version of the HTTP client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript type stripping&lt;/strong&gt; still works out of the box for &lt;code&gt;.ts&lt;/code&gt; files, as it does on Node 24. &lt;code&gt;--experimental-transform-types&lt;/code&gt; was removed, so enums and namespaces that need transformation won't run without a build step.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node 26 only&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;word&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&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;b&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;a&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;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;counts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOrInsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Temporal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;plainDateISO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Asia/Karachi&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;today&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;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Iterator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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="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="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// [1, 2, 3]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Breaking changes to check before upgrading
&lt;/h2&gt;

&lt;p&gt;These are the ones most likely to hit a typical Node.js, NestJS or Next.js codebase:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Native addons must be rebuilt.&lt;/strong&gt; &lt;code&gt;NODE_MODULE_VERSION&lt;/code&gt; went from 137 to 147. Anything with a compiled &lt;code&gt;.node&lt;/code&gt; file (bcrypt, sharp, better-sqlite3, canvas) needs a fresh install on Node 26. Delete &lt;code&gt;node_modules&lt;/code&gt; in your Docker build rather than copying it from a Node 24 stage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;http.ServerResponse.prototype.writeHeader()&lt;/code&gt; is gone.&lt;/strong&gt; It was a legacy alias. On v26.13.0 &lt;code&gt;typeof res.writeHeader&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;. Use &lt;code&gt;writeHead()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;_stream_*&lt;/code&gt; legacy modules are removed&lt;/strong&gt; (&lt;code&gt;_stream_readable&lt;/code&gt;, &lt;code&gt;_stream_writable&lt;/code&gt; and friends). Old dependencies that &lt;code&gt;require('_stream_readable')&lt;/code&gt; will crash at import.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;module.register()&lt;/code&gt; is runtime-deprecated.&lt;/strong&gt; Running a loader that calls it prints &lt;code&gt;DEP0205 DeprecationWarning: module.register() is deprecated. Use module.registerHooks() instead.&lt;/code&gt; It still works, but plan to move custom loaders to &lt;code&gt;registerHooks()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--experimental-transform-types&lt;/code&gt; is removed.&lt;/strong&gt; If you run &lt;code&gt;.ts&lt;/code&gt; with enums directly through Node, switch to &lt;code&gt;erasableSyntaxOnly&lt;/code&gt; style code or keep a compiler step.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A quick way to find the first two in a codebase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"writeHeader(&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;_stream_readable&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;_stream_writable&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;module.register("&lt;/span&gt; src/ node_modules/.pnpm 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Node 24 vs Node 26 performance: benchmark setup
&lt;/h2&gt;

&lt;p&gt;I ran these on November 3, 2026 on one Linux VM with 8 vCPUs (Intel Xeon) and 16 GB RAM. The machine was shared with other work (load average around 1.5 before starting), so treat small differences as noise. To limit that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each benchmark process was pinned to one CPU with &lt;code&gt;taskset&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Node 24 and Node 26 runs were &lt;strong&gt;interleaved over 5 rounds&lt;/strong&gt;, and I report the median of the 5 rounds.&lt;/li&gt;
&lt;li&gt;The HTTP load generator (autocannon, 100 connections, 10 s) always ran on Node 24 on separate CPUs, so only the server changed.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;fetch()&lt;/code&gt; test sent 20,000 requests with 50 concurrent loops to a fixed Node 24 server, so only the client changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scripts are plain &lt;code&gt;.mjs&lt;/code&gt; files: &lt;code&gt;JSON.parse&lt;/code&gt;/&lt;code&gt;JSON.stringify&lt;/code&gt; on a 20,000-item document, sorting 300,000 numbers, setting 1 million string keys in a &lt;code&gt;Map&lt;/code&gt;, a global regex replace, SHA-256 over 16 MB, allocating 2 million small class instances, and 1 million sequential &lt;code&gt;await&lt;/code&gt;s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmark results
&lt;/h2&gt;

&lt;p&gt;Lower is better for times, higher is better for requests per second. Median of 5 rounds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Node 24.22.2&lt;/th&gt;
&lt;th&gt;Node 26.13.0&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JSON.parse&lt;/code&gt; (20k items)&lt;/td&gt;
&lt;td&gt;11.79 ms&lt;/td&gt;
&lt;td&gt;10.46 ms&lt;/td&gt;
&lt;td&gt;11% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JSON.stringify&lt;/code&gt; (20k items)&lt;/td&gt;
&lt;td&gt;7.20 ms&lt;/td&gt;
&lt;td&gt;4.53 ms&lt;/td&gt;
&lt;td&gt;37% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sort 300k numbers&lt;/td&gt;
&lt;td&gt;54.6 ms&lt;/td&gt;
&lt;td&gt;51.7 ms&lt;/td&gt;
&lt;td&gt;5% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Map.set&lt;/code&gt; 1M string keys&lt;/td&gt;
&lt;td&gt;396.7 ms&lt;/td&gt;
&lt;td&gt;507.5 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28% slower&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regex replace&lt;/td&gt;
&lt;td&gt;2.06 ms&lt;/td&gt;
&lt;td&gt;1.72 ms&lt;/td&gt;
&lt;td&gt;16% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SHA-256 over 16 MB&lt;/td&gt;
&lt;td&gt;9.57 ms&lt;/td&gt;
&lt;td&gt;9.17 ms&lt;/td&gt;
&lt;td&gt;4% faster (within noise)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2M class instances&lt;/td&gt;
&lt;td&gt;47.8 ms&lt;/td&gt;
&lt;td&gt;35.0 ms&lt;/td&gt;
&lt;td&gt;27% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M sequential &lt;code&gt;await&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;58.6 ms&lt;/td&gt;
&lt;td&gt;35.0 ms&lt;/td&gt;
&lt;td&gt;40% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;node -e 0&lt;/code&gt; startup&lt;/td&gt;
&lt;td&gt;22.3 ms&lt;/td&gt;
&lt;td&gt;20.7 ms&lt;/td&gt;
&lt;td&gt;7% faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSS after startup&lt;/td&gt;
&lt;td&gt;41 MB&lt;/td&gt;
&lt;td&gt;41 MB&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;node:http&lt;/code&gt; server, req/s&lt;/td&gt;
&lt;td&gt;73,079&lt;/td&gt;
&lt;td&gt;79,653&lt;/td&gt;
&lt;td&gt;9% more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;node:http&lt;/code&gt; p99 latency&lt;/td&gt;
&lt;td&gt;3 ms&lt;/td&gt;
&lt;td&gt;2 ms&lt;/td&gt;
&lt;td&gt;lower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;fetch()&lt;/code&gt; client, req/s&lt;/td&gt;
&lt;td&gt;8,442&lt;/td&gt;
&lt;td&gt;7,601&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10% fewer&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What stood out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;JSON.stringify&lt;/code&gt; and &lt;code&gt;await&lt;/code&gt; got much cheaper.&lt;/strong&gt; Those two show up everywhere in API servers, so the 9% higher &lt;code&gt;node:http&lt;/code&gt; throughput is consistent with them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fetch()&lt;/code&gt; was slower in 4 of 5 rounds.&lt;/strong&gt; Node 26 ships Undici 8. If your service makes lots of outbound HTTP calls, measure your own workload before and after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Map.set&lt;/code&gt; with 1 million new string keys was slower in every round.&lt;/strong&gt; The Node 24 numbers for this test varied a lot (336 to 513 ms), so the size of the gap is uncertain, but the direction was consistent. Code that builds very large maps in a hot path is worth profiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are microbenchmarks on one VM. They tell you where to look, not what your app will do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to upgrade from Node 24 to Node 26
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Switch locally&lt;/span&gt;
nvm &lt;span class="nb"&gt;install &lt;/span&gt;26 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; nvm use 26     &lt;span class="c"&gt;# or: fnm use 26 / volta install node@26&lt;/span&gt;

&lt;span class="c"&gt;# 2. Reinstall so native addons rebuild against ABI 147&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; node_modules &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm ci

&lt;span class="c"&gt;# 3. Surface deprecations your code triggers&lt;/span&gt;
&lt;span class="nv"&gt;NODE_OPTIONS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;--trace-deprecation&lt;/span&gt; npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then update the places that pin the version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Dockerfile&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:26-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;package.json&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;"engines"&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;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=26"&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;Also update &lt;code&gt;.nvmrc&lt;/code&gt;, your CI matrix and the Node version in your hosting panel. If you deploy Next.js in Docker, the multi-stage setup in my &lt;a href="https://fabwaseem.com/blog/nextjs-dockerfile" rel="noopener noreferrer"&gt;Next.js Dockerfile guide&lt;/a&gt; works the same with &lt;code&gt;node:26-alpine&lt;/code&gt; as the base image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you upgrade to Node 26 now?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New projects:&lt;/strong&gt; start on Node 26. It's the Active LTS line and is supported a year longer than Node 24.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing Node 24 apps:&lt;/strong&gt; there's no rush. Node 24 gets security fixes until April 30, 2028. Upgrade when you next touch your Docker base image, after rebuilding native addons and running your tests with &lt;code&gt;--trace-deprecation&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Services that are mostly outbound HTTP:&lt;/strong&gt; benchmark your own &lt;code&gt;fetch()&lt;/code&gt; traffic first, because that was the one place Node 26 was clearly slower in my test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're also weighing runtimes rather than versions, I compared &lt;a href="https://fabwaseem.com/blog/bun-vs-node" rel="noopener noreferrer"&gt;Bun vs Node&lt;/a&gt; on real workloads earlier.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is Node 26 LTS?
&lt;/h3&gt;

&lt;p&gt;Yes. Node 26 entered Active LTS on October 28, 2026 with v26.13.0, codename "Lithium". It stays Active LTS until October 20, 2027, then Maintenance LTS until April 30, 2029.&lt;/p&gt;

&lt;h3&gt;
  
  
  When does Node 24 reach end of life?
&lt;/h3&gt;

&lt;p&gt;April 30, 2028. Node 24 has been in Maintenance LTS since October 20, 2026, which means security and critical fixes only.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Node 26 faster than Node 24?
&lt;/h3&gt;

&lt;p&gt;In my benchmarks on one 8-vCPU VM, Node 26 was faster at &lt;code&gt;JSON.stringify&lt;/code&gt; (37%), sequential &lt;code&gt;await&lt;/code&gt; (40%) and as a &lt;code&gt;node:http&lt;/code&gt; server (9% more requests per second). It was slower for the &lt;code&gt;fetch()&lt;/code&gt; client (10% fewer requests per second) and for inserting 1 million keys into a &lt;code&gt;Map&lt;/code&gt; (28% slower). Measure your own workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  What breaks when upgrading from Node 24 to Node 26?
&lt;/h3&gt;

&lt;p&gt;The common ones are native addons that need rebuilding (&lt;code&gt;NODE_MODULE_VERSION&lt;/code&gt; 147), the removed &lt;code&gt;res.writeHeader()&lt;/code&gt; alias, the removed &lt;code&gt;_stream_*&lt;/code&gt; modules, the removed &lt;code&gt;--experimental-transform-types&lt;/code&gt; flag, and a runtime deprecation warning for &lt;code&gt;module.register()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Node 26 have Temporal?
&lt;/h3&gt;

&lt;p&gt;Yes. The &lt;code&gt;Temporal&lt;/code&gt; global is enabled by default in Node 26. In the official Node 24.22.2 binary, &lt;code&gt;typeof Temporal&lt;/code&gt; is &lt;code&gt;'undefined'&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://fabwaseem.com/blog/node-24-vs-node-26" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Vitest vs Jest vs node:test in 2026: Benchmarked on a Real TypeScript Project</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Tue, 03 Nov 2026 06:43:36 +0000</pubDate>
      <link>https://dev.to/fabwaseem/vitest-vs-jest-vs-nodetest-in-2026-benchmarked-on-a-real-typescript-project-4bj5</link>
      <guid>https://dev.to/fabwaseem/vitest-vs-jest-vs-nodetest-in-2026-benchmarked-on-a-real-typescript-project-4bj5</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/vitest-vs-jest-vs-node-test" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I ran the same TypeScript test suite under Vitest 5.1, Jest 30.5 and Node's built-in &lt;code&gt;node:test&lt;/code&gt;, and timed cold runs, warm runs, single files, watch mode and coverage. The suite is 39 test files and 183 tests against a small NestJS-style orders service, an HTTP API and an MCP server. The result surprised me: with default settings, &lt;strong&gt;Jest with &lt;code&gt;@swc/jest&lt;/code&gt; was the fastest full run, and Vitest was the slowest of the three&lt;/strong&gt;. Change one isolation setting and the order flips. Below are the numbers, the setup for each runner, and the TypeScript and ESM problems I hit along the way.&lt;/p&gt;

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

&lt;p&gt;Everything ran on November 3, 2026 on one Linux VM with 8 vCPUs (Intel Xeon) and 16 GB of RAM, on Node.js 24.22.1:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vitest 5.1.0&lt;/strong&gt; (Vite 8.3.5), &lt;code&gt;@vitest/coverage-v8&lt;/code&gt; 5.1.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jest 30.5.2&lt;/strong&gt; with &lt;code&gt;@swc/jest&lt;/code&gt; 0.2.39 and &lt;code&gt;@swc/core&lt;/code&gt; 1.16.21, and separately with &lt;code&gt;ts-jest&lt;/code&gt; 29.4.15&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;node:test&lt;/strong&gt; from Node 24.22.1, using Node's built-in type stripping (no loader)&lt;/li&gt;
&lt;li&gt;TypeScript 6.0.3 for type checks. &lt;code&gt;ts-jest&lt;/code&gt; 29.4.15 declares &lt;code&gt;typescript &amp;gt;=4.3 &amp;lt;7&lt;/code&gt;, so it doesn't support TypeScript 7.0.2, the current &lt;code&gt;latest&lt;/code&gt; on npm.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The code under test is real, not generated: money math with cent allocation, slugs, cursor pagination, an LRU cache, a token-bucket rate limiter, retry with backoff, a CSV parser, an event bus, Zod order schemas, a pricing service, an orders service with status transitions, a &lt;code&gt;node:http&lt;/code&gt; JSON API, and the MCP server from my &lt;a href="https://fabwaseem.com/blog/test-mcp-server" rel="noopener noreferrer"&gt;MCP testing guide&lt;/a&gt;. That's 387 lines of source and 501 lines of tests. The HTTP and MCP tests start real servers on random ports and call them with &lt;code&gt;fetch&lt;/code&gt; and the official MCP SDK client.&lt;/p&gt;

&lt;p&gt;To keep the suite identical, every test file imports &lt;code&gt;describe&lt;/code&gt;, &lt;code&gt;it&lt;/code&gt; and hooks from &lt;code&gt;#t&lt;/code&gt;, a one-line shim that each runner maps to its own API, and asserts with &lt;code&gt;node:assert/strict&lt;/code&gt;. Fakes are hand-written, so no runner-specific mocking runs inside the benchmark. I compare the mocking APIs separately below.&lt;/p&gt;

&lt;p&gt;How I measured: a script ran each configuration 10 times in shuffled order. "Cold" means I deleted the runner's caches first (&lt;code&gt;jest --clearCache&lt;/code&gt;, and &lt;code&gt;node_modules/.vite&lt;/code&gt; plus &lt;code&gt;node_modules/.vitest-cache&lt;/code&gt; for Vitest). "Warm" is the next run with caches in place. Times are whole-process wall clock, and I report the median. Every run had to exit 0 with all 183 tests passing.&lt;/p&gt;

&lt;p&gt;One honest caveat: this VM is shared, and other jobs were running (load average between 3 and 13 on 8 cores). Absolute times are noisy, so read the ranking more than the decimals. I ran the main configurations in two separate batches, and Vitest, Jest and node:test each landed within about 0.1 s of their first-batch medians.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: the full suite
&lt;/h2&gt;

&lt;p&gt;39 files, 183 tests, median of 10 runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runner and config&lt;/th&gt;
&lt;th&gt;Cold&lt;/th&gt;
&lt;th&gt;Warm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vitest 5.1, defaults (&lt;code&gt;forks&lt;/code&gt; pool, isolated)&lt;/td&gt;
&lt;td&gt;1.59 s&lt;/td&gt;
&lt;td&gt;1.51 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vitest, &lt;code&gt;--pool=threads&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.55 s&lt;/td&gt;
&lt;td&gt;1.42 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vitest, &lt;code&gt;--pool=vmThreads&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.91 s&lt;/td&gt;
&lt;td&gt;0.91 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vitest, &lt;code&gt;--no-isolate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.75 s&lt;/td&gt;
&lt;td&gt;0.68 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vitest, &lt;code&gt;--fsModuleCache&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.63 s&lt;/td&gt;
&lt;td&gt;1.52 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest 30.5 + &lt;code&gt;@swc/jest&lt;/code&gt; (CommonJS output)&lt;/td&gt;
&lt;td&gt;0.99 s&lt;/td&gt;
&lt;td&gt;0.81 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest + &lt;code&gt;@swc/jest&lt;/code&gt;, native ESM (&lt;code&gt;--experimental-vm-modules&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;1.19 s&lt;/td&gt;
&lt;td&gt;1.01 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest + &lt;code&gt;ts-jest&lt;/code&gt; (type-checks every file)&lt;/td&gt;
&lt;td&gt;4.30 s&lt;/td&gt;
&lt;td&gt;3.13 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest + &lt;code&gt;ts-jest&lt;/code&gt;, &lt;code&gt;isolatedModules: true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.44 s&lt;/td&gt;
&lt;td&gt;1.14 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;node --test&lt;/code&gt; (process per file)&lt;/td&gt;
&lt;td&gt;1.23 s&lt;/td&gt;
&lt;td&gt;1.24 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node --test --test-isolation=none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.52 s&lt;/td&gt;
&lt;td&gt;0.51 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The threads, vmThreads and &lt;code&gt;--test-isolation=none&lt;/code&gt; rows come from the second batch. In that batch, the Vitest default, Jest + &lt;code&gt;@swc/jest&lt;/code&gt; and &lt;code&gt;node --test&lt;/code&gt; rows measured 1.52/1.40 s, 0.95/0.80 s and 1.14/1.23 s.&lt;/p&gt;

&lt;p&gt;What the numbers say:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Isolation is most of the cost.&lt;/strong&gt; Vitest's default &lt;code&gt;forks&lt;/code&gt; pool starts a fresh child process for each test file, and its own summary told me so: "39 workers spawned, ~81ms startup each". &lt;code&gt;node --test&lt;/code&gt; also runs each file in its own process. Jest isolates each file in a separate &lt;code&gt;vm&lt;/code&gt; context but reuses its 7 worker processes. Turn isolation off and Vitest (0.68 s) and node:test (0.51 s) both beat Jest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vmThreads&lt;/code&gt; is the middle ground for Vitest.&lt;/strong&gt; It kept per-file isolation and cut the run from 1.51 s to 0.91 s. The catch is that it runs each file in a &lt;code&gt;vm&lt;/code&gt; sandbox, like Jest does. Vitest's docs warn that this leaks memory on large suites (workers restart when they pass &lt;code&gt;vmMemoryLimit&lt;/code&gt;) and that objects from Node APIs such as &lt;code&gt;fetch&lt;/code&gt;'s &lt;code&gt;response.json()&lt;/code&gt; and &lt;code&gt;structuredClone()&lt;/code&gt; have a different prototype. That's the same realm problem I hit with Jest below, and this suite only passed under &lt;code&gt;vmThreads&lt;/code&gt; because I had already fixed those tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ts-jest&lt;/code&gt; with type checking costs about 2.3 seconds here.&lt;/strong&gt; It was 3.13 s warm against 0.81 s for &lt;code&gt;@swc/jest&lt;/code&gt;. With &lt;code&gt;isolatedModules: true&lt;/code&gt; it skips the type checker and drops to 1.14 s. Running &lt;code&gt;tsc --noEmit&lt;/code&gt; on the whole project separately took about 1.5 s (median of 5), so a separate type-check step is cheaper than type-checking inside Jest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caches barely mattered at this size.&lt;/strong&gt; Cold and warm runs differ by 0.1 to 0.2 s for Vitest and Jest with SWC. Vitest 5 made &lt;code&gt;fsModuleCache&lt;/code&gt; stable, but on a project this small it made no measurable difference. &lt;code&gt;node --test&lt;/code&gt; has no transform cache at all, because type stripping is cheap.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results at 4x the size
&lt;/h2&gt;

&lt;p&gt;To see how the gap scales, I copied the test tree three more times: 156 files and 732 tests, same source. Median of 7 runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runner and config&lt;/th&gt;
&lt;th&gt;Cold&lt;/th&gt;
&lt;th&gt;Warm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vitest, defaults&lt;/td&gt;
&lt;td&gt;5.15 s&lt;/td&gt;
&lt;td&gt;5.03 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vitest, &lt;code&gt;--pool=vmThreads&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2.29 s&lt;/td&gt;
&lt;td&gt;1.99 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vitest, &lt;code&gt;--no-isolate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.32 s&lt;/td&gt;
&lt;td&gt;1.08 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest + &lt;code&gt;@swc/jest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.75 s&lt;/td&gt;
&lt;td&gt;1.52 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node --test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4.32 s&lt;/td&gt;
&lt;td&gt;4.26 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node --test --test-isolation=none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.08 s&lt;/td&gt;
&lt;td&gt;1.10 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The per-file process cost grows with the file count. Isolated Vitest went from 1.5 s to 5.0 s, while Jest went from 0.8 s to 1.5 s. Without isolation, Vitest and node:test stay around one second. On a busy machine, process startup is exactly the thing that gets slower, so your numbers on a quiet laptop may be closer together, but the direction should hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  One file, the inner loop
&lt;/h2&gt;

&lt;p&gt;Running only &lt;code&gt;test/orders/service.create.test.ts&lt;/code&gt; (4 tests), warm, median of 7:&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;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node --test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.18 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest + &lt;code&gt;@swc/jest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.39 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vitest&lt;/td&gt;
&lt;td&gt;0.40 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest + &lt;code&gt;ts-jest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.40 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Node has nothing to boot except itself. Vitest and Jest each spend about 0.2 s more starting their runner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watch mode
&lt;/h2&gt;

&lt;p&gt;I started each runner in watch mode, waited for the first run, then edited a source file and timed the rerun (median of 5). &lt;code&gt;money.ts&lt;/code&gt; is imported by 3 test files. &lt;code&gt;events.ts&lt;/code&gt; is imported by the shared test fixtures, so 18 files depend on it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Watch command&lt;/th&gt;
&lt;th&gt;Edit &lt;code&gt;money.ts&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Edit &lt;code&gt;events.ts&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vitest --watch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.23 s, 3 files&lt;/td&gt;
&lt;td&gt;0.97 s, 18 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jest --watch&lt;/code&gt; (needs git)&lt;/td&gt;
&lt;td&gt;0.25 s, 3 files&lt;/td&gt;
&lt;td&gt;0.52 s, 18 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jest --watchAll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.66 s, all 39 files&lt;/td&gt;
&lt;td&gt;0.61 s, all 39 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node --test --watch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.31 s, 3 files&lt;/td&gt;
&lt;td&gt;0.94 s, 18 files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three rerun only the affected test files. Vitest follows the module graph, and node:test did the same in my test. Jest's &lt;code&gt;--watch&lt;/code&gt; uses git to find changed files, and outside a git repo it refuses to start ("--watch is not supported without git/hg, please use --watchAll"). Vitest's interactive watch UI is the nicest of the three (filter by name, rerun failed tests, the optional browser UI), but on raw rerun speed they're close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage
&lt;/h2&gt;

&lt;p&gt;Full suite with coverage on, median of 5 runs after one warm-up:&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;Time&lt;/th&gt;
&lt;th&gt;Lines reported&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vitest, &lt;code&gt;@vitest/coverage-v8&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.99 s&lt;/td&gt;
&lt;td&gt;97.38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest + &lt;code&gt;@swc/jest&lt;/code&gt;, default (Babel/Istanbul)&lt;/td&gt;
&lt;td&gt;0.99 s&lt;/td&gt;
&lt;td&gt;97.81%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest + &lt;code&gt;@swc/jest&lt;/code&gt;, &lt;code&gt;--coverageProvider=v8&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.66 s&lt;/td&gt;
&lt;td&gt;98.44%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;node --test --experimental-test-coverage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.36 s&lt;/td&gt;
&lt;td&gt;100% lines, 95.79% branches&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Coverage added about 0.5 s to Vitest and less to Jest with Istanbul. The percentages aren't comparable between tools, because each counts lines, statements and branches differently. Node said 100% of lines while the others found a few uncovered lines in the same code. Pick one tool for your coverage threshold and stay with it. Node's coverage is still behind the &lt;code&gt;--experimental-test-coverage&lt;/code&gt; flag on Node 24.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup for each runner
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Vitest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vitest.config.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;defineConfig&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;vitest/config&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;include&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;test/**/*.test.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;coverage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;v8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;include&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;src/**&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;// isolate: false,   // 0.68 s instead of 1.51 s, if your tests don't leak state&lt;/span&gt;
    &lt;span class="c1"&gt;// pool: "vmThreads", // keeps isolation, 0.91 s here&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's all it needed. ESM, TypeScript, &lt;code&gt;.ts&lt;/code&gt; extensions in imports, top-level await and &lt;code&gt;import.meta&lt;/code&gt; all worked without extra config. The exception is decorators: a class with a legacy decorator and a constructor parameter property failed with "SyntaxError: Invalid or unexpected token" under my bare config, even with &lt;code&gt;experimentalDecorators&lt;/code&gt; in &lt;code&gt;tsconfig.json&lt;/code&gt;. Adding &lt;code&gt;unplugin-swc&lt;/code&gt; 2.0.0 to the plugins, with &lt;code&gt;experimentalDecorators&lt;/code&gt; and &lt;code&gt;emitDecoratorMetadata&lt;/code&gt; in tsconfig, fixed it. If you're on NestJS 12, the Vitest config that &lt;code&gt;nest new&lt;/code&gt; generates already handles this. I covered that in my &lt;a href="https://fabwaseem.com/blog/nestjs-12" rel="noopener noreferrer"&gt;NestJS 12 migration guide&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Jest with @swc/jest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// jest.config.cjs&lt;/span&gt;
&lt;span class="cm"&gt;/** @type {import('jest').Config} */&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;testEnvironment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;testMatch&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;&amp;lt;rootDir&amp;gt;/test/**/*.test.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;transform&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;^.+&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.ts$&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@swc/jest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works if SWC compiles your ESM TypeScript to CommonJS, which is what happens when you don't list &lt;code&gt;.ts&lt;/code&gt; in &lt;code&gt;extensionsToTreatAsEsm&lt;/code&gt;. It broke in three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Top-level await and &lt;code&gt;import.meta&lt;/code&gt;.&lt;/strong&gt; My MCP server file had an &lt;code&gt;if (import.meta.url === ...)&lt;/code&gt; block with a top-level &lt;code&gt;await&lt;/code&gt; to start the server when run directly. As CommonJS, Jest failed to parse it: "await is only valid in async functions and the top level bodies of modules". I moved the entry point to its own &lt;code&gt;main.ts&lt;/code&gt;, which is cleaner anyway, and used the same code for all three runners.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realm mismatches with &lt;code&gt;node:assert&lt;/code&gt;.&lt;/strong&gt; Jest runs each file in a &lt;code&gt;vm&lt;/code&gt; context with its own &lt;code&gt;Object.prototype&lt;/code&gt;. Objects created by Node's globals, like &lt;code&gt;Response.json()&lt;/code&gt; from &lt;code&gt;fetch&lt;/code&gt; or &lt;code&gt;structuredClone()&lt;/code&gt;, come from the outer realm. &lt;code&gt;assert.deepStrictEqual&lt;/code&gt; checks prototypes, so it failed with "Compared values have no visual difference". Jest's own &lt;code&gt;expect().toEqual()&lt;/code&gt; and &lt;code&gt;toStrictEqual()&lt;/code&gt; passed on the same objects. Vitest's default pool and node:test didn't have the problem. I changed those tests to &lt;code&gt;JSON.parse(await r.text())&lt;/code&gt; for all runners.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;jest.mock&lt;/code&gt; hoisting.&lt;/strong&gt; With &lt;code&gt;@swc/jest&lt;/code&gt; 0.2.39, a &lt;code&gt;jest.mock()&lt;/code&gt; call wasn't hoisted when &lt;code&gt;jest&lt;/code&gt; was imported from &lt;code&gt;@jest/globals&lt;/code&gt;. The real module ran, and my "must not send email" guard threw. Using the global &lt;code&gt;jest&lt;/code&gt; object fixed it, and so did &lt;code&gt;ts-jest&lt;/code&gt;, which hoisted the same file correctly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For native ESM, add &lt;code&gt;extensionsToTreatAsEsm: [".ts"]&lt;/code&gt;, tell SWC to keep ES modules, and start Node with &lt;code&gt;--experimental-vm-modules&lt;/code&gt;. On Node 24.22, Jest 30.5 still printed "VM Modules is an experimental feature" for every worker. In this mode, module mocks use &lt;code&gt;jest.unstable_mockModule()&lt;/code&gt; followed by a dynamic &lt;code&gt;import()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// jest.esm.config.cjs, run with NODE_OPTIONS=--experimental-vm-modules&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;testEnvironment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;testMatch&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;&amp;lt;rootDir&amp;gt;/test/**/*.test.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;extensionsToTreatAsEsm&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;.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;transform&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;^.+&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.ts$&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@swc/jest&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;module&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;es6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Jest with ts-jest
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ts-jest&lt;/code&gt; uses the TypeScript compiler, so it type-checks your tests, which explains the 3.1 s run. It also brings tsconfig friction. My project tsconfig uses &lt;code&gt;"module": "nodenext"&lt;/code&gt; and &lt;code&gt;verbatimModuleSyntax&lt;/code&gt; in a &lt;code&gt;"type": "module"&lt;/code&gt; package, and ts-jest's CommonJS mode failed every file with TS1295: "ECMAScript imports and exports cannot be written in a CommonJS file under 'verbatimModuleSyntax'". I needed a separate &lt;code&gt;tsconfig.jest.json&lt;/code&gt; with &lt;code&gt;"module": "commonjs"&lt;/code&gt;, &lt;code&gt;verbatimModuleSyntax: false&lt;/code&gt;, and a &lt;code&gt;paths&lt;/code&gt; entry for the &lt;code&gt;#t&lt;/code&gt; import (Node-style &lt;code&gt;imports&lt;/code&gt; aren't resolved with &lt;code&gt;node10&lt;/code&gt; resolution). You also can't move to TypeScript 7 with ts-jest yet, because of the &lt;code&gt;&amp;lt;7&lt;/code&gt; peer range.&lt;/p&gt;

&lt;h3&gt;
  
  
  node:test
&lt;/h3&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;"scripts"&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;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;test/**/*.test.ts&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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;"test:watch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test --watch &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;test/**/*.test.ts&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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;"test:cov"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test --experimental-test-coverage --test-coverage-include=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;src/**&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;test/**/*.test.ts&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No dependencies and no config. Node 24 strips types by default, and the test runner finds &lt;code&gt;.ts&lt;/code&gt; files from the glob. The price is that your TypeScript has to be erasable: Node removes types but doesn't compile anything. I checked each case on Node 24.22.1:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;&lt;code&gt;node file.ts&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;--experimental-transform-types&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enum Status { ... }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;constructor(private readonly repo: Repo)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;@Injectable()&lt;/code&gt; decorator&lt;/td&gt;
&lt;td&gt;ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX&lt;/td&gt;
&lt;td&gt;SyntaxError&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a NestJS codebase full of decorators and parameter properties can't run under plain &lt;code&gt;node --test&lt;/code&gt;. My orders service is written NestJS-style, with constructor injection, but it assigns dependencies to private fields by hand instead of using parameter properties. That makes it erasable, and &lt;code&gt;"erasableSyntaxOnly": true&lt;/code&gt; in tsconfig keeps it that way. You also need &lt;code&gt;.ts&lt;/code&gt; extensions in relative imports (&lt;code&gt;allowImportingTsExtensions&lt;/code&gt; plus &lt;code&gt;noEmit&lt;/code&gt; for &lt;code&gt;tsc&lt;/code&gt;) and &lt;code&gt;import type&lt;/code&gt; for type-only imports (&lt;code&gt;verbatimModuleSyntax&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Mocking APIs compared
&lt;/h2&gt;

&lt;p&gt;The same two jobs in each runner: replace an ES module that sends email, and fake a 60-second timer. All three files below passed.&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;// Vitest: vi.mock is hoisted above the imports&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;vi&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;expect&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;vitest&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;sendReceipt&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;./receipts.ts&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;sendEmail&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;./mailer.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./mailer.ts&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="na"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vi&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;msg_1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="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="s2"&gt;mocks an ES module&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&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;sendReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a@b.co&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;msg_1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toHaveBeenCalledWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a@b.co&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;Receipt for order #7&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// timers: vi.useFakeTimers(); vi.advanceTimersByTime(60_000);&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;// Jest (CommonJS output): use the global jest object so jest.mock gets hoisted&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;sendReceipt&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;./receipts.ts&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;sendEmail&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;./mailer.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./mailer.ts&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="na"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jest&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;msg_1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="c1"&gt;// timers: jest.useFakeTimers(); jest.advanceTimersByTime(60_000);&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;// node:test: needs --experimental-test-module-mocks, then a dynamic import&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;mock&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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="s2"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sendEmail&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_subject&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;msg_1&lt;/span&gt;&lt;span class="dl"&gt;"&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;module&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./mailer.ts&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;exports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sendEmail&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;sendReceipt&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./receipts.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;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mocks an ES module&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendReceipt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a@b.co&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;msg_1&lt;/span&gt;&lt;span class="dl"&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;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sendEmail&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="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;arguments&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;a@b.co&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;Receipt for order #7&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// timers: mock.timers.enable({ apis: ["setTimeout"] }); mock.timers.tick(60_000);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vitest has the smoothest module mocking for ESM. Jest's is fine in CommonJS but needs &lt;code&gt;unstable_mockModule&lt;/code&gt; and dynamic imports in ESM. Node's &lt;code&gt;mock.module()&lt;/code&gt; works, but it's experimental: without the flag, &lt;code&gt;mock.module&lt;/code&gt; is not a function. Node 24 also prints a deprecation warning if you use the older &lt;code&gt;namedExports&lt;/code&gt; option instead of &lt;code&gt;exports&lt;/code&gt;. Node's &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; need no flag and covered everything else I needed. For most service code, constructor injection with hand-written fakes, like this suite uses, avoids module mocking altogether.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which test runner should you use?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pick Vitest&lt;/strong&gt; for a new TypeScript or ESM project, a Vite or React frontend, or a NestJS 12 app. It needed the least configuration, handled ESM and top-level await without flags, and has the best mocking and watch experience. Its default per-file process isolation is the slowest option at scale, so try &lt;code&gt;pool: "vmThreads"&lt;/code&gt; or &lt;code&gt;isolate: false&lt;/code&gt; once your suite is stable. Those settings made it the fastest or close to it in my runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep Jest&lt;/strong&gt; if you have a large, working Jest suite, especially CommonJS. With &lt;code&gt;@swc/jest&lt;/code&gt; it was the fastest default configuration here, and moving to Vitest is mostly an API rename (&lt;code&gt;jest.fn&lt;/code&gt; to &lt;code&gt;vi.fn&lt;/code&gt;) that buys you little speed unless you also change isolation. Swap &lt;code&gt;ts-jest&lt;/code&gt; for &lt;code&gt;@swc/jest&lt;/code&gt; and run &lt;code&gt;tsc --noEmit&lt;/code&gt; separately: that alone took this suite from 3.13 s to 0.81 s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick node:test&lt;/strong&gt; for libraries, CLIs and backend code written in erasable TypeScript, where zero dependencies matters. It had the fastest single-file runs (0.18 s) and the fastest full run without isolation (0.51 s). You give up a rich &lt;code&gt;expect&lt;/code&gt;, snapshot ergonomics and stable module mocking, and decorator-heavy code won't run. If you're comparing runtimes too, I measured &lt;a href="https://fabwaseem.com/blog/bun-vs-node" rel="noopener noreferrer"&gt;Bun against Node&lt;/a&gt; separately, and I covered what changes in &lt;a href="https://fabwaseem.com/blog/typescript-7" rel="noopener noreferrer"&gt;TypeScript 7&lt;/a&gt; for build tooling.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is Vitest faster than Jest?
&lt;/h3&gt;

&lt;p&gt;Not with default settings in my test. On a 39-file TypeScript suite, Jest 30.5 with @swc/jest took 0.81 s warm and Vitest 5.1 took 1.51 s, because Vitest's default forks pool starts a process per test file. With isolate: false Vitest took 0.68 s, and with pool: vmThreads 0.91 s. At 156 files the default gap grew to 1.52 s for Jest against 5.03 s for Vitest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can node:test run TypeScript without a loader?
&lt;/h3&gt;

&lt;p&gt;Yes, on Node 24 type stripping is on by default and node --test picks up .ts files from a glob. It only removes types, so enums, constructor parameter properties and decorators fail with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. You also need .ts extensions in relative imports and import type for type-only imports.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use ts-jest or @swc/jest?
&lt;/h3&gt;

&lt;p&gt;Use @swc/jest and run tsc --noEmit as a separate step. In my runs ts-jest took 3.13 s warm because it type-checks every file, against 0.81 s for @swc/jest, while tsc on the whole project took about 1.5 s. ts-jest 29.4.15 also declares typescript below 7, so it doesn't support TypeScript 7 yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Jest support ESM in 2026?
&lt;/h3&gt;

&lt;p&gt;Yes, but still behind Node's --experimental-vm-modules flag. Jest 30.5 on Node 24.22 ran my suite as native ESM in 1.01 s warm, but printed an experimental warning for every worker, and module mocks need jest.unstable_mockModule plus a dynamic import. Compiling to CommonJS with @swc/jest avoids the flag but breaks top-level await and import.meta.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which test runner has the fastest watch mode?
&lt;/h3&gt;

&lt;p&gt;They were close. After editing a file imported by 3 test files, Vitest reran in 0.23 s, jest --watch in 0.25 s and node --test --watch in 0.31 s, and all three reran only the affected files. jest --watch needs a git repository; jest --watchAll reruns every file.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://fabwaseem.com/blog/vitest-vs-jest-vs-node-test" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>typescript</category>
      <category>node</category>
      <category>vitest</category>
    </item>
    <item>
      <title>Postgres LISTEN/NOTIFY vs Redis Pub/Sub: Latency, Write Cost and Failure Cases, Tested</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Mon, 02 Nov 2026 13:14:34 +0000</pubDate>
      <link>https://dev.to/fabwaseem/postgres-listennotify-vs-redis-pubsub-latency-write-cost-and-failure-cases-tested-42bl</link>
      <guid>https://dev.to/fabwaseem/postgres-listennotify-vs-redis-pubsub-latency-write-cost-and-failure-cases-tested-42bl</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/postgres-listen-notify-vs-redis-pubsub" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you already run Postgres, you can push realtime events to your app without adding Redis: &lt;code&gt;NOTIFY&lt;/code&gt; sends a message on commit, and every connection that ran &lt;code&gt;LISTEN&lt;/code&gt; gets it. I measured Postgres 18 LISTEN/NOTIFY against Redis 8 Pub/Sub for delivery latency, fan-out, and the cost to the transactions that send the events. I also checked what each one does when a message is too big, when a transaction rolls back, and when a listener disconnects. The short answer: Postgres is fast enough for most realtime features, and it's the safer choice when the event describes a database write. Redis has steadier tail latency under load.&lt;/p&gt;

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

&lt;p&gt;Everything ran on one 8 vCPU / 16 GB Linux VM on October 30, 2026: PostgreSQL 18.6 (&lt;code&gt;fsync&lt;/code&gt; and &lt;code&gt;synchronous_commit&lt;/code&gt; on, &lt;code&gt;shared_buffers=512MB&lt;/code&gt;), Redis 8.0.2 with persistence off, Node.js 22.20, &lt;code&gt;pg&lt;/code&gt; 8.23.1 and &lt;code&gt;ioredis&lt;/code&gt; 6.0.0. Subscribers ran in separate Node processes. Publishers sent 100-byte JSON messages with a timestamp, and subscribers measured the time until the message arrived. Each fan-out test ran 10 seconds, and I report the median of 3 runs.&lt;/p&gt;

&lt;p&gt;This is one machine with client and server on localhost, so the numbers show how the two compare, not what you'll see across a network.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery latency and fan-out
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Subscribers&lt;/th&gt;
&lt;th&gt;Messages/s&lt;/th&gt;
&lt;th&gt;Postgres p50&lt;/th&gt;
&lt;th&gt;Postgres p99&lt;/th&gt;
&lt;th&gt;Redis p50&lt;/th&gt;
&lt;th&gt;Redis p99&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;0.34 ms&lt;/td&gt;
&lt;td&gt;0.99 ms&lt;/td&gt;
&lt;td&gt;0.17 ms&lt;/td&gt;
&lt;td&gt;0.92 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;0.18 ms&lt;/td&gt;
&lt;td&gt;2.60 ms&lt;/td&gt;
&lt;td&gt;0.19 ms&lt;/td&gt;
&lt;td&gt;1.07 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;25,000&lt;/td&gt;
&lt;td&gt;0.39 ms&lt;/td&gt;
&lt;td&gt;5.65 ms&lt;/td&gt;
&lt;td&gt;0.16 ms&lt;/td&gt;
&lt;td&gt;0.69 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;0.22 ms&lt;/td&gt;
&lt;td&gt;2.60 ms&lt;/td&gt;
&lt;td&gt;0.17 ms&lt;/td&gt;
&lt;td&gt;1.15 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;0.34 ms&lt;/td&gt;
&lt;td&gt;9.83 ms&lt;/td&gt;
&lt;td&gt;0.23 ms&lt;/td&gt;
&lt;td&gt;0.96 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;0.52 ms&lt;/td&gt;
&lt;td&gt;4.32 ms&lt;/td&gt;
&lt;td&gt;0.36 ms&lt;/td&gt;
&lt;td&gt;2.39 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither lost a message in any run. The typical (p50) delivery time is under a millisecond for both. The difference is in the tail: as the message rate or listener count goes up, Postgres p99 climbs faster, and it spikes more between runs. With 10 listeners at 10,000 messages a second, Postgres p99 ranged from 5.8 ms to 79.6 ms across the three runs, while Redis stayed between 0.9 and 2.2 ms. One Postgres run at 25,000 messages a second hit a 113.7 ms p99.&lt;/p&gt;

&lt;p&gt;For chat messages, notifications, dashboard refreshes and "your export is ready" events, a few milliseconds at p99 doesn't matter. For a high-rate stream where tail latency matters, Redis is the steadier option.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NOTIFY costs the writer
&lt;/h2&gt;

&lt;p&gt;Latency is only half of it. The bigger question is what happens to the transactions that send the events. I ran 16 and 64 clients, each doing &lt;code&gt;BEGIN&lt;/code&gt;, an &lt;code&gt;INSERT&lt;/code&gt; into an &lt;code&gt;events&lt;/code&gt; table, and &lt;code&gt;COMMIT&lt;/code&gt;, for 10 seconds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;16 clients (tx/s)&lt;/th&gt;
&lt;th&gt;64 clients (tx/s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain INSERT&lt;/td&gt;
&lt;td&gt;22,990&lt;/td&gt;
&lt;td&gt;25,346&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INSERT + &lt;code&gt;pg_notify&lt;/code&gt; in the same statement&lt;/td&gt;
&lt;td&gt;13,668 to 17,360&lt;/td&gt;
&lt;td&gt;14,246 to 17,254&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INSERT with an &lt;code&gt;AFTER INSERT&lt;/code&gt; trigger calling &lt;code&gt;pg_notify&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;18,596&lt;/td&gt;
&lt;td&gt;13,760 to 15,716&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INSERT, commit, then Redis &lt;code&gt;PUBLISH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;20,756&lt;/td&gt;
&lt;td&gt;19,334 to 19,506&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ranges are separate 10-second runs, with and without a listener connected. These runs were noisy, so read them as a band, not exact figures. Sending a notification in the transaction cost roughly 20 to 45% of commit throughput in my runs. Committing a transaction that sent a &lt;code&gt;NOTIFY&lt;/code&gt; adds its messages to a single shared queue, and Postgres holds a database-wide lock while it does that so notifications stay in commit order. The commits that notify take turns.&lt;/p&gt;

&lt;p&gt;Publishing to Redis after the commit costs the database much less, because Postgres does nothing extra. But you get that speed by giving up the guarantee in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Behavior checks: where they really differ
&lt;/h2&gt;

&lt;p&gt;I scripted each of these against both systems. Here's what I found:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Postgres LISTEN/NOTIFY&lt;/th&gt;
&lt;th&gt;Redis Pub/Sub&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Message sent inside a transaction that rolls back&lt;/td&gt;
&lt;td&gt;Not delivered&lt;/td&gt;
&lt;td&gt;Delivered if you published before the rollback: the subscriber got an event for a row that doesn't exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;When listeners see it&lt;/td&gt;
&lt;td&gt;Only after &lt;code&gt;COMMIT&lt;/code&gt; (0 seen before, 1 after)&lt;/td&gt;
&lt;td&gt;Immediately on &lt;code&gt;PUBLISH&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload size&lt;/td&gt;
&lt;td&gt;7,999 bytes OK, 8,000 bytes fails with "payload string too long"&lt;/td&gt;
&lt;td&gt;1 MB delivered fine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same channel and payload sent 6 times in one transaction&lt;/td&gt;
&lt;td&gt;Collapsed: listener got 2 (the 2 distinct payloads)&lt;/td&gt;
&lt;td&gt;Every publish delivered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Listener offline while 10 messages were sent&lt;/td&gt;
&lt;td&gt;0 received after reconnect&lt;/td&gt;
&lt;td&gt;0 received after reconnect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slow consumer&lt;/td&gt;
&lt;td&gt;A listener stuck in an open transaction holds the queue: 200,000 notifications of 1 KB filled 2.4% of it, cleared after the listener committed&lt;/td&gt;
&lt;td&gt;Subscriber was disconnected after about 35 MB of unread messages (&lt;code&gt;client-output-buffer-limit pubsub 32mb 8mb 60&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of these matter in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NOTIFY is transactional.&lt;/strong&gt; If the insert rolls back, nobody hears about it. With Redis you either publish before the commit and risk announcing a write that never happened, or publish after it and risk losing the event if the process crashes between &lt;code&gt;COMMIT&lt;/code&gt; and &lt;code&gt;PUBLISH&lt;/code&gt;. For "this row changed" events, Postgres gets this right without extra code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both are fire-and-forget.&lt;/strong&gt; A listener that is disconnected misses everything sent while it was gone. Neither system queues messages for you. If you need delivery guarantees, use a job queue or Redis Streams. I compared &lt;a href="https://fabwaseem.com/blog/postgres-job-queue-pg-boss-vs-bullmq" rel="noopener noreferrer"&gt;Postgres job queues with BullMQ&lt;/a&gt; separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres collapses duplicates in a transaction.&lt;/strong&gt; If you send the same channel and payload twice in one transaction, listeners get it once. That's good for "something changed, refetch" signals and surprising if you use NOTIFY as a counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern I use: NOTIFY as a doorbell, the table as the truth
&lt;/h2&gt;

&lt;p&gt;Because notifications can be missed and payloads are capped below 8,000 bytes, I don't put the data in the notification. I write the event to a table, send only the row id in the &lt;code&gt;NOTIFY&lt;/code&gt;, and let the server read the row. A reconnecting client catches up from the table using the last id it saw. Here's the whole thing with Node's built-in HTTP server and Server-Sent Events, one &lt;code&gt;LISTEN&lt;/code&gt; connection per process, fanned out to browsers by room:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server.mjs: one LISTEN connection per Node process, fanned out to many SSE clients.&lt;/span&gt;
&lt;span class="c1"&gt;// The events table is the source of truth; NOTIFY is only the doorbell.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&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:http&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;pg&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cfg&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;rooms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// room -&amp;gt; Set&amp;lt;{ res, lastId }&amp;gt;&lt;/span&gt;

&lt;span class="kd"&gt;function&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;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&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;// bigserial comes back from pg as a string: compare numbers, not strings&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;id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastId&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 sent (backfill + notify can overlap)&lt;/span&gt;
  &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`id: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\ndata: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;backfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT id, room, body FROM events WHERE room = $1 AND id &amp;gt; $2 ORDER BY id LIMIT 500&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;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastId&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&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;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;listen&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;listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// handle notifications one at a time so ids stay in order&lt;/span&gt;
  &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notification&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;payload&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;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chain&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="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// payload stays tiny: ids only (8000-byte limit)&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&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;subs&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT id, room, body FROM events WHERE id = $1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="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;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;subs&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;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&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;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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;listener error:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;end&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="c1"&gt;// Notifications sent while we're disconnected are gone. Reconnect, then backfill from the table.&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;listen&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;console&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="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LISTEN events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subs&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;subs&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;backfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;room&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="na"&gt;lastId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;last-event-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;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="s1"&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="s1"&gt;no-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keep-alive&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&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="se"&gt;\n\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&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;Set&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close&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;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&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;backfill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// catch up after a browser reconnect (Last-Event-ID)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &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;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/messages&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;let&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;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;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&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;room&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="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;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// INSERT and NOTIFY in one statement: the notification is sent only if the row commits&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;rows&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`WITH ins AS (INSERT INTO events (room, body) VALUES ($1, $2) RETURNING id, room)
       SELECT id, pg_notify('events', json_build_object('id', id, 'room', room)::text) FROM ins`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;end&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;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;listen&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;PORT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;3400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;listen&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;console&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table is &lt;code&gt;CREATE TABLE events (id bigserial primary key, room int not null, body jsonb not null)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I tested it with a script that opens three SSE clients (two in room 1, one in room 2), posts 10 messages, then kills the server's &lt;code&gt;LISTEN&lt;/code&gt; connection with &lt;code&gt;pg_terminate_backend&lt;/code&gt; and posts 3 more while it's down. Results on PostgreSQL 18.6:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each room-1 client got its 5 messages, and the room-2 client got only room-2 messages.&lt;/li&gt;
&lt;li&gt;After the listener was killed, the server reconnected and the backfill delivered all 3 "gap" messages, with 0 duplicates.&lt;/li&gt;
&lt;li&gt;A browser reconnecting with &lt;code&gt;Last-Event-ID&lt;/code&gt; got exactly the 3 messages it had missed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The bug the test caught.&lt;/strong&gt; My first version compared &lt;code&gt;row.id &amp;lt;= client.lastId&lt;/code&gt; directly. &lt;code&gt;pg&lt;/code&gt; returns &lt;code&gt;bigint&lt;/code&gt; and &lt;code&gt;bigserial&lt;/code&gt; columns as strings, because they can be larger than JavaScript numbers can hold exactly. Once ids reached two digits, &lt;code&gt;"10" &amp;lt;= "8"&lt;/code&gt; was true as a string comparison. The room-2 client silently dropped its 5th message, and the backfill after the reconnect sent nothing. Converting with &lt;code&gt;Number()&lt;/code&gt; fixed both. If your ids may go past 2^53, compare &lt;code&gt;BigInt&lt;/code&gt;s instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a direct connection for LISTEN.&lt;/strong&gt; PgBouncer in transaction pooling mode doesn't support &lt;code&gt;LISTEN&lt;/code&gt;, because your session isn't kept between transactions. Keep one dedicated connection per app instance for listening and use the pool for everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never sit in an open transaction on the listening connection.&lt;/strong&gt; Notifications are delivered between transactions. A listener that stays inside a transaction holds the shared queue for everyone, and the queue is 8 GB by default. Watch &lt;code&gt;SELECT pg_notification_queue_usage()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send ids, not data.&lt;/strong&gt; The payload limit is just under 8,000 bytes, and a small payload keeps the queue small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for missed messages.&lt;/strong&gt; Both Postgres and Redis drop messages for anyone who isn't connected. Backfill from a table, or use a queue when every event must be processed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch notify-heavy write paths.&lt;/strong&gt; If one table does thousands of commits a second, test the throughput cost above on your own hardware before adding a trigger that notifies on every row.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Which one should you use?
&lt;/h2&gt;

&lt;p&gt;Use &lt;strong&gt;Postgres LISTEN/NOTIFY&lt;/strong&gt; when the events come from your own database writes: new chat messages, order status changes, "this job finished", cache invalidation. You get transactional delivery and no extra service to run. That's how I'd add realtime updates to a &lt;a href="https://fabwaseem.com/blog/nestjs-server-sent-events" rel="noopener noreferrer"&gt;NestJS app with Server-Sent Events&lt;/a&gt; that already uses Postgres.&lt;/p&gt;

&lt;p&gt;Use &lt;strong&gt;Redis Pub/Sub&lt;/strong&gt; when you need steadier latency at high message rates, payloads above 8 KB, or events that don't come from a database write, like presence, typing indicators or live cursors. If you already run Redis for &lt;a href="https://fabwaseem.com/blog/ai-automation-pipeline-nodejs-bullmq" rel="noopener noreferrer"&gt;BullMQ job queues&lt;/a&gt;, adding Pub/Sub costs nothing extra.&lt;/p&gt;

&lt;p&gt;Use &lt;strong&gt;neither&lt;/strong&gt; when every message must be processed. That's a job queue or a stream.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is Postgres LISTEN/NOTIFY fast enough for realtime apps?
&lt;/h3&gt;

&lt;p&gt;For most apps, yes. In my test on PostgreSQL 18.6 the median delivery time was 0.18 to 0.52 ms from 1 to 100 listeners at up to 25,000 messages a second, with no lost messages. Its p99 latency was higher and less steady than Redis Pub/Sub under load, up to about 10 ms at 10,000 messages a second with 10 listeners.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does NOTIFY slow down writes?
&lt;/h3&gt;

&lt;p&gt;It can. With 16 to 64 clients doing single-row inserts, sending pg_notify in the same transaction ran at about 55 to 80% of plain insert throughput in my runs. Commits that send notifications append to one shared queue in commit order, so they take turns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a NOTIFY sent if the transaction rolls back?
&lt;/h3&gt;

&lt;p&gt;No. Notifications are delivered only when the transaction commits, and a rolled-back transaction sends nothing. Redis Pub/Sub has no link to your database transaction, so a publish before the rollback still reaches subscribers.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the maximum NOTIFY payload size?
&lt;/h3&gt;

&lt;p&gt;The payload must be shorter than 8,000 bytes in a default PostgreSQL build. In my test a 7,999-byte payload worked and 8,000 bytes failed with 'payload string too long'. Send an id and read the row instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does LISTEN/NOTIFY work with PgBouncer?
&lt;/h3&gt;

&lt;p&gt;Not in transaction pooling mode, because LISTEN needs the same session to stay connected. Use one direct database connection per app instance for LISTEN and the pool for everything else.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://fabwaseem.com/blog/postgres-listen-notify-vs-redis-pubsub" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>redis</category>
      <category>node</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to Test an MCP Server: Vitest, In-Memory Transport, Streamable HTTP and the Inspector CLI</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Sat, 31 Oct 2026 06:05:29 +0000</pubDate>
      <link>https://dev.to/fabwaseem/how-to-test-an-mcp-server-vitest-in-memory-transport-streamable-http-and-the-inspector-cli-17p9</link>
      <guid>https://dev.to/fabwaseem/how-to-test-an-mcp-server-vitest-in-memory-transport-streamable-http-and-the-inspector-cli-17p9</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://fabwaseem.com/blog/test-mcp-server" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing an MCP server&lt;/strong&gt; means checking three layers: the pure functions behind your tools, the MCP contract (tool list, schemas, results and errors), and the transport your clients actually use. You can cover all three with Vitest and the official TypeScript SDK, without starting an AI client, and the whole suite below runs in about 300 ms.&lt;/p&gt;

&lt;p&gt;I wrote this while building &lt;a href="https://fabwaseem.com/projects/benchline" rel="noopener noreferrer"&gt;Benchline&lt;/a&gt;, my set of browser tools that are also a stateless MCP server. Every tool there is a pure function first and an MCP tool second, and that split is what makes testing easy. Below is a small, complete example server you can copy, the tests I run against it, and the MCP Inspector CLI commands for manual checks. Everything was run with &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; 1.32.2, Vitest 5.0.5 and Node 22.20, and I re-ran the full suite on October 31, 2026 (1.32.2 is still the latest SDK release) with the same result.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit-test the logic&lt;/strong&gt; as plain functions. Most bugs live there, and these tests need no MCP at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract-test the server in memory&lt;/strong&gt; with &lt;code&gt;InMemoryTransport.createLinkedPair()&lt;/code&gt; and a real SDK &lt;code&gt;Client&lt;/code&gt;: list tools, check schemas, call each tool, and assert on errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the transport once&lt;/strong&gt; over Streamable HTTP: the SDK client connects, &lt;code&gt;initialize&lt;/code&gt; returns 200, wrong &lt;code&gt;Accept&lt;/code&gt; headers get 406, &lt;code&gt;GET&lt;/code&gt; gets 405 on a stateless server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check by hand&lt;/strong&gt; with &lt;code&gt;npx @modelcontextprotocol/inspector --cli &amp;lt;url&amp;gt; --transport http --method tools/list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In SDK 1.32.2, a schema failure and an unknown tool both came back as a tool result with &lt;code&gt;isError: true&lt;/code&gt;, not as a thrown error. Write your assertions so they hold either way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The server under test
&lt;/h2&gt;

&lt;p&gt;Two read-only tools: &lt;code&gt;slugify&lt;/code&gt;, with a typed output schema, and &lt;code&gt;json_format&lt;/code&gt;, which must report bad input as an error result instead of crashing.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```ts title="src/server.ts"&lt;br&gt;
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";&lt;br&gt;
import { z } from "zod";&lt;/p&gt;

&lt;p&gt;export function slugify(input: string) {&lt;br&gt;
  return input.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase()&lt;br&gt;
    .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;export function createServer() {&lt;br&gt;
  const server = new McpServer({ name: "demo-tools", version: "1.0.0" });&lt;/p&gt;

&lt;p&gt;server.registerTool("slugify", {&lt;br&gt;
    title: "Slugify",&lt;br&gt;
    description: "Turn a title into a URL slug (lowercase ASCII, words joined by hyphens).",&lt;br&gt;
    inputSchema: { text: z.string().min(1).max(500) },&lt;br&gt;
    outputSchema: { slug: z.string() },&lt;br&gt;
    annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },&lt;br&gt;
  }, async ({ text }) =&amp;gt; {&lt;br&gt;
    const slug = slugify(text);&lt;br&gt;
    return { content: [{ type: "text", text: slug }], structuredContent: { slug } };&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;server.registerTool("json_format", {&lt;br&gt;
    title: "Format JSON",&lt;br&gt;
    description: "Pretty-print a JSON string with the given indent. Returns an error result for invalid JSON.",&lt;br&gt;
    inputSchema: { json: z.string(), indent: z.number().int().min(0).max(8).default(2) },&lt;br&gt;
    annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },&lt;br&gt;
  }, async ({ json, indent }) =&amp;gt; {&lt;br&gt;
    try {&lt;br&gt;
      return { content: [{ type: "text", text: JSON.stringify(JSON.parse(json), null, indent) }] };&lt;br&gt;
    } catch (e) {&lt;br&gt;
      return { isError: true, content: [{ type: "text", text: &lt;code&gt;Invalid JSON: ${(e as Error).message}&lt;/code&gt; }] };&lt;br&gt;
    }&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;return server;&lt;br&gt;
}&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


The key design choice is that `slugify` is exported on its own. The MCP handler is a thin wrapper, so the logic is tested like any other function.

## Layer 1: unit tests for the tool logic



```ts title="test/unit.test.ts"
import { describe, it, expect } from "vitest";
import { slugify } from "../src/server.ts";

describe("slugify (pure function)", () =&amp;gt; {
  it("lowercases and joins words", () =&amp;gt; expect(slugify("Hello, MCP World!")).toBe("hello-mcp-world"));
  it("strips accents", () =&amp;gt; expect(slugify("Crème Brûlée")).toBe("creme-brulee"));
  it("trims separators", () =&amp;gt; expect(slugify("  --a--  ")).toBe("a"));
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Nothing MCP-specific here, and that is the point. In Benchline, each tool folder has a pure &lt;code&gt;lib/&lt;/code&gt; covered by Vitest. The MCP layer only maps inputs and outputs.&lt;/p&gt;
&lt;h2&gt;
  
  
  Layer 2: contract tests with the in-memory transport
&lt;/h2&gt;

&lt;p&gt;The SDK ships &lt;code&gt;InMemoryTransport&lt;/code&gt;, which links a client and a server inside one process. You test the real protocol (JSON-RPC messages, schema validation, result shapes) with no ports and no network.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```ts title="test/mcp.test.ts"&lt;br&gt;
import { describe, it, expect, beforeAll, afterAll } from "vitest";&lt;br&gt;
import { Client } from "@modelcontextprotocol/sdk/client/index.js";&lt;br&gt;
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";&lt;br&gt;
import { createServer } from "../src/server.ts";&lt;/p&gt;

&lt;p&gt;let client: Client;&lt;br&gt;
beforeAll(async () =&amp;gt; {&lt;br&gt;
  const [clientT, serverT] = InMemoryTransport.createLinkedPair();&lt;br&gt;
  await createServer().connect(serverT);&lt;br&gt;
  client = new Client({ name: "test", version: "1.0.0" });&lt;br&gt;
  await client.connect(clientT);&lt;br&gt;
});&lt;br&gt;
afterAll(() =&amp;gt; client.close());&lt;/p&gt;

&lt;p&gt;describe("MCP contract (in-memory)", () =&amp;gt; {&lt;br&gt;
  it("lists tools with object schemas and descriptions", async () =&amp;gt; {&lt;br&gt;
    const { tools } = await client.listTools();&lt;br&gt;
    expect(tools.length).toBeGreaterThan(0);&lt;br&gt;
    for (const t of tools) {&lt;br&gt;
      expect(t.name).toMatch(/^[a-z][a-z0-9_]{0,63}$/);&lt;br&gt;
      expect(t.description?.length).toBeGreaterThan(20);&lt;br&gt;
      expect(t.inputSchema.type).toBe("object");&lt;br&gt;
      expect(t.annotations?.readOnlyHint).toBe(true);&lt;br&gt;
    }&lt;br&gt;
    expect(new Set(tools.map((t) =&amp;gt; t.name)).size).toBe(tools.length);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("returns structured content that matches the output schema", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "slugify", arguments: { text: "Hello MCP" } });&lt;br&gt;
    expect(r.isError).toBeFalsy();&lt;br&gt;
    expect(r.structuredContent).toEqual({ slug: "hello-mcp" });&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("applies schema defaults", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "json_format", arguments: { json: '{"a":1}' } });&lt;br&gt;
    expect((r.content as any)[0].text).toBe('{\n  "a": 1\n}');&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("reports bad input as a tool error, not a crash", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "json_format", arguments: { json: "{oops" } });&lt;br&gt;
    expect(r.isError).toBe(true);&lt;br&gt;
    expect((r.content as any)[0].text).toMatch(/^Invalid JSON/);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("rejects arguments that fail the input schema", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "slugify", arguments: { text: "" } }).catch((e) =&amp;gt; e);&lt;br&gt;
    if (r instanceof Error) expect((r as any).code).toBe(-32602);&lt;br&gt;
    else expect((r as any).isError).toBe(true);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("errors on unknown tools", async () =&amp;gt; {&lt;br&gt;
    const r = await client.callTool({ name: "nope", arguments: {} }).catch((e) =&amp;gt; e);&lt;br&gt;
    expect(r instanceof Error ? true : (r as any).isError).toBe(true);&lt;br&gt;
  });&lt;br&gt;
});&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


What these tests catch:

- **Schema regressions.** Every tool must have an object `inputSchema` and a useful description. A model picks tools by name and description, so an empty description is a real bug.
- **Annotation drift.** If a tool is meant to be read-only, assert `readOnlyHint`. Clients can use annotations to decide when to ask the user for confirmation.
- **Duplicate names** after a refactor.
- **Error shape.** A handler that throws on bad input is worse than one that returns `isError: true` with a clear message, because the model can read the message and retry.

Two results surprised me, so I logged them before writing the assertions. With SDK 1.32.2, calling `slugify` with an empty string (which fails `min(1)`) and calling a tool that doesn't exist both resolved to a result with `isError: true`. The unknown-tool text was `MCP error -32602: Tool nope not found`. Older SDK versions and other server frameworks may throw a JSON-RPC error instead, so the tests accept both shapes.

## Layer 3: the Streamable HTTP transport

Clients like Cursor, Claude Code and VS Code connect over HTTP, so test that path once. This server is stateless: a new `McpServer` and transport per request and no session ID. Benchline's public endpoint is stateless in the same way: no API key and no session.



```ts title="src/http.ts"
import { createServer as createHttpServer } from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createServer } from "./server.ts";

// Stateless Streamable HTTP: a new server + transport per request, no session id.
export function startHttp(port = 0) {
  const http = createHttpServer(async (req, res) =&amp;gt; {
    if (req.url !== "/mcp") { res.writeHead(404).end(); return; }
    if (req.method !== "POST") { res.writeHead(405, { Allow: "POST" }).end(); return; }
    const chunks: Buffer[] = []; for await (const c of req) chunks.push(c as Buffer);
    const body = JSON.parse(Buffer.concat(chunks).toString() || "null");
    const server = createServer();
    const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
    res.on("close", () =&amp;gt; { transport.close(); server.close(); });
    await server.connect(transport);
    await transport.handleRequest(req, res, body);
  });
  return new Promise&amp;lt;{ url: URL; close: () =&amp;gt; void }&amp;gt;((resolve) =&amp;gt;
    http.listen(port, "127.0.0.1", () =&amp;gt; {
      const { port } = http.address() as { port: number };
      resolve({ url: new URL(`http://127.0.0.1:${port}/mcp`), close: () =&amp;gt; http.close() });
    }));
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const { url } = await startHttp(Number(process.env.PORT ?? 3321));
  console.log(`MCP server on ${url}`);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;




&lt;p&gt;```ts title="test/http.test.ts"&lt;br&gt;
import { describe, it, expect, beforeAll, afterAll } from "vitest";&lt;br&gt;
import { Client } from "@modelcontextprotocol/sdk/client/index.js";&lt;br&gt;
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";&lt;br&gt;
import { startHttp } from "../src/http.ts";&lt;/p&gt;

&lt;p&gt;let srv: Awaited&amp;gt;;&lt;br&gt;
const url = () =&amp;gt; process.env.MCP_URL ? new URL(process.env.MCP_URL) : srv.url;&lt;br&gt;
beforeAll(async () =&amp;gt; { if (!process.env.MCP_URL) srv = await startHttp(); });&lt;br&gt;
afterAll(() =&amp;gt; srv?.close());&lt;/p&gt;

&lt;p&gt;const init = { jsonrpc: "2.0", id: 1, method: "initialize",&lt;br&gt;
  params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "curl", version: "1" } } };&lt;/p&gt;

&lt;p&gt;describe("Streamable HTTP transport", () =&amp;gt; {&lt;br&gt;
  it("works with the official SDK client", async () =&amp;gt; {&lt;br&gt;
    const c = new Client({ name: "http-test", version: "1.0.0" });&lt;br&gt;
    await c.connect(new StreamableHTTPClientTransport(url()));&lt;br&gt;
    const { tools } = await c.listTools();&lt;br&gt;
    expect(tools.length).toBeGreaterThan(0);&lt;br&gt;
    await c.close();&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("answers initialize without a session id (stateless)", async () =&amp;gt; {&lt;br&gt;
    const r = await fetch(url(), { method: "POST", body: JSON.stringify(init),&lt;br&gt;
      headers: { "content-type": "application/json", accept: "application/json, text/event-stream" } });&lt;br&gt;
    expect(r.status).toBe(200);&lt;br&gt;
    expect(r.headers.get("mcp-session-id")).toBeNull();&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("rejects a client that does not accept both content types", async () =&amp;gt; {&lt;br&gt;
    const r = await fetch(url(), { method: "POST", body: JSON.stringify(init),&lt;br&gt;
      headers: { "content-type": "application/json", accept: "application/json" } });&lt;br&gt;
    expect(r.status).toBe(406);&lt;br&gt;
  });&lt;/p&gt;

&lt;p&gt;it("returns 405 for GET when there is no SSE stream", async () =&amp;gt; {&lt;br&gt;
    const r = await fetch(url(), { headers: { accept: "text/event-stream" } });&lt;br&gt;
    expect(r.status).toBe(405);&lt;br&gt;
  });&lt;br&gt;
});&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


All four passed. Facts worth pinning in tests, because a framework upgrade can silently change them:

- `initialize` returned **200** with no `mcp-session-id` header, so clients don't need to track a session.
- A request with `Accept: application/json` only returned **406**. The Streamable HTTP spec requires clients to accept both `application/json` and `text/event-stream`, and some hand-written clients and proxies don't.
- `GET /mcp` returned **405**, which tells clients there is no server-to-client SSE stream on a stateless server.
- In stateless mode, `tools/list` worked without an `initialize` call first. A raw curl got back a 1,120-byte JSON response for the two tools.

Set `MCP_URL` to run the same file against a deployed server: `MCP_URL=https://your-host/mcp npx vitest run test/http.test.ts`. I ran it against the local server started with `node src/http.ts`, and all 4 tests passed.

Response size is worth a test too. Everything in `tools/list` is sent to the model, so it costs context on every conversation. I measured why that matters in [MCP vs API](https://fabwaseem.com/blog/mcp-vs-api).

## Manual checks with the MCP Inspector CLI

The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) has a web UI and a CLI mode. The CLI is handy in a terminal or a CI smoke step (I first ran it with version 2.12.0, and the same commands gave the same output with 2.13.0, the latest release on October 31, 2026):



```bash title="Inspector CLI"
# list tools
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3321/mcp \
  --transport http --method tools/list

# call one tool
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3321/mcp \
  --transport http --method tools/call --tool-name slugify --tool-arg 'text=Hello MCP World'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The call printed:&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;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hello-mcp-world"&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;"structuredContent"&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;"slug"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hello-mcp-world"&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;And raw curl, for when you suspect the client:&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;```bash title="curl"&lt;br&gt;
curl -s &lt;a href="http://127.0.0.1:3321/mcp" rel="noopener noreferrer"&gt;http://127.0.0.1:3321/mcp&lt;/a&gt; \&lt;br&gt;
  -H 'content-type: application/json' \&lt;br&gt;
  -H 'accept: application/json, text/event-stream' \&lt;br&gt;
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'&lt;/p&gt;

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


## Running it in CI



```json title="package.json (scripts)"
{
  "scripts": {
    "test": "vitest run",
    "test:remote": "vitest run test/http.test.ts"
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm test&lt;/code&gt; runs all three layers. In 3 test files and 13 tests, Vitest reported a duration of 295 ms. Run &lt;code&gt;test:remote&lt;/code&gt; with &lt;code&gt;MCP_URL&lt;/code&gt; set after each deploy as a smoke test. It catches a broken proxy, a missing route or a CDN that strips headers, which is exactly what unit tests can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to test, as a checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every tool: unit tests for its logic, including edge cases and bad input.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tools/list&lt;/code&gt;: unique names, object input schemas, descriptions long enough to be useful, expected annotations.&lt;/li&gt;
&lt;li&gt;Each tool through &lt;code&gt;callTool&lt;/code&gt;: one happy path, one error path that returns &lt;code&gt;isError: true&lt;/code&gt; with a readable message.&lt;/li&gt;
&lt;li&gt;Output schemas: &lt;code&gt;structuredContent&lt;/code&gt; matches what you declared.&lt;/li&gt;
&lt;li&gt;Transport: SDK client connects; &lt;code&gt;initialize&lt;/code&gt; status and session header; &lt;code&gt;Accept&lt;/code&gt; handling (406); &lt;code&gt;GET&lt;/code&gt; behavior (405 when stateless).&lt;/li&gt;
&lt;li&gt;Auth, if you have it: no token gets 401 with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header, and a wrong-audience token is rejected. The &lt;a href="https://fabwaseem.com/blog/mcp-security" rel="noopener noreferrer"&gt;MCP security guide&lt;/a&gt; covers the cases worth testing.&lt;/li&gt;
&lt;li&gt;Size: keep an eye on the &lt;code&gt;tools/list&lt;/code&gt; byte count as you add tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building your first server, start with &lt;a href="https://fabwaseem.com/blog/build-mcp-server-nextjs" rel="noopener noreferrer"&gt;how to build an MCP server in Next.js&lt;/a&gt; and &lt;a href="https://fabwaseem.com/blog/mcp-server-stateless" rel="noopener noreferrer"&gt;stateless MCP servers&lt;/a&gt;. Then connect it to your editor with &lt;a href="https://fabwaseem.com/blog/add-remote-mcp-server" rel="noopener noreferrer"&gt;how to add a remote MCP server&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I test an MCP server without an AI client?
&lt;/h3&gt;

&lt;p&gt;Use the official SDK's Client class in your test runner. Connect it to your server with InMemoryTransport.createLinkedPair() for fast in-process tests, or with StreamableHTTPClientTransport for the HTTP path, then call listTools and callTool and assert on the results. No model or editor is needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is InMemoryTransport in the MCP SDK?
&lt;/h3&gt;

&lt;p&gt;InMemoryTransport, exported from @modelcontextprotocol/sdk/inMemory.js, creates a linked pair of transports inside one process. Connect your server to one end and a Client to the other, and they exchange real JSON-RPC MCP messages without any network, which makes contract tests fast and deterministic.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I use the MCP Inspector CLI?
&lt;/h3&gt;

&lt;p&gt;Run npx @modelcontextprotocol/inspector --cli followed by the server URL, --transport http and a method. For example --method tools/list lists tools, and --method tools/call --tool-name slugify --tool-arg 'text=Hello' calls one. It prints the JSON result, which makes it useful for scripts and CI smoke checks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should an MCP tool throw or return isError?
&lt;/h3&gt;

&lt;p&gt;Return a result with isError: true and a clear text message for expected failures such as invalid input. The model can read the message and try again. In my tests with SDK 1.32.2, even schema validation failures and unknown tool names came back as isError results.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my MCP server return 406 Not Acceptable?
&lt;/h3&gt;

&lt;p&gt;The Streamable HTTP transport requires the client to send an Accept header listing both application/json and text/event-stream. A request with only Accept: application/json got 406 from the SDK's server transport in my test. Fix the client or the proxy that rewrites the header.&lt;/p&gt;




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

</description>
      <category>mcp</category>
      <category>typescript</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>Free OG Image Generator: Open Graph Image Sizes and Dynamic OG Images in Next.js</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 30 Oct 2026 07:03:55 +0000</pubDate>
      <link>https://dev.to/fabwaseem/free-og-image-generator-open-graph-image-sizes-and-dynamic-og-images-in-nextjs-2h0k</link>
      <guid>https://dev.to/fabwaseem/free-og-image-generator-open-graph-image-sizes-and-dynamic-og-images-in-nextjs-2h0k</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/free-og-image-generator" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;free OG image generator&lt;/strong&gt; turns a title, a subtitle and a few colors into a 1200×630 PNG that link previews can display. I built one you can use right now, with no account and no watermark: &lt;strong&gt;&lt;a href="https://tools.fabwaseem.com/t/og-image-generator" rel="noopener noreferrer"&gt;open the free OG image generator&lt;/a&gt;&lt;/strong&gt;. It exports a ready PNG, gives you a live image URL, and writes the matching Next.js &lt;code&gt;opengraph-image.tsx&lt;/code&gt; file for the template you picked.&lt;/p&gt;

&lt;p&gt;This guide covers what an OG image is, the correct &lt;strong&gt;Open Graph image size&lt;/strong&gt; for every major platform, how to use the tool, how to generate &lt;strong&gt;dynamic OG images in Next.js&lt;/strong&gt; with &lt;code&gt;next/og&lt;/code&gt;, the exact meta tags you need, and how to debug a preview that refuses to update.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an OG image?
&lt;/h2&gt;

&lt;p&gt;An OG (Open Graph) image is the picture a social network or messaging app shows when someone shares a link. The page declares it with an &lt;code&gt;og:image&lt;/code&gt; meta tag, defined by the Open Graph protocol, and the platform downloads that URL. Without one, the platform shows no image or guesses one from the page, which rarely looks good.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://ogp.me/" rel="noopener noreferrer"&gt;Open Graph protocol&lt;/a&gt; was created by Facebook and is now read by LinkedIn, Slack, Discord, WhatsApp, iMessage and most chat apps. X (Twitter) reads its own &lt;code&gt;twitter:*&lt;/code&gt; tags first and falls back to Open Graph tags when they are missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Definition:&lt;/strong&gt; an &lt;em&gt;OG image&lt;/em&gt; is the image referenced by a page's &lt;code&gt;og:image&lt;/code&gt; meta tag. A &lt;em&gt;dynamic OG image&lt;/em&gt; is one rendered on request (or at build time) from code, usually with the page title baked in, instead of a static file designed by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the correct Open Graph image size?
&lt;/h2&gt;

&lt;p&gt;Use 1200×630 pixels, a 1.91:1 aspect ratio, as a PNG or JPG under 5 MB. Facebook recommends at least 1200×630 for high-resolution displays, LinkedIn asks for at least 1200×627 at 1.91:1, and X's large card crops toward 2:1 but displays a 1200×630 image fine. One 1200×630 file covers almost every platform.&lt;/p&gt;

&lt;p&gt;Keep the important text inside a safe zone: leave roughly 60 to 80 px of padding on every side, because some apps crop the edges or round the corners. Large, high-contrast titles survive small mobile previews far better than paragraphs of small text.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OG and Twitter card sizes does each platform use?
&lt;/h2&gt;

&lt;p&gt;Each platform publishes slightly different rules, but they converge on 1.91:1. The table below lists the recommended size and the hard limits from each platform's own documentation, so you can design one image and know where it might be cropped.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Recommended size&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;th&gt;Limits from official docs&lt;/th&gt;
&lt;th&gt;Tag read&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Facebook&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Minimum 200 × 200; 600 × 315 for large previews; max 8 MB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;1200 × 627&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Minimum 1200 × 627; max 5 MB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X (summary_large_image)&lt;/td&gt;
&lt;td&gt;1200 × 600 to 1200 × 675&lt;/td&gt;
&lt;td&gt;2:1&lt;/td&gt;
&lt;td&gt;Minimum 300 × 157; max 4096 × 4096; under 5 MB; JPG, PNG, WEBP or GIF&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;twitter:image&lt;/code&gt;, falls back to &lt;code&gt;og:image&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;X (summary)&lt;/td&gt;
&lt;td&gt;144 × 144 or larger&lt;/td&gt;
&lt;td&gt;1:1&lt;/td&gt;
&lt;td&gt;Max 4096 × 4096; under 5 MB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;twitter:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Unfurls use Open Graph tags&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discord&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Embeds use Open Graph tags&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;Small files load more reliably&lt;/td&gt;
&lt;td&gt;&lt;code&gt;og:image&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js file convention&lt;/td&gt;
&lt;td&gt;1200 × 630&lt;/td&gt;
&lt;td&gt;1.91:1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;opengraph-image&lt;/code&gt; max 8 MB, &lt;code&gt;twitter-image&lt;/code&gt; max 5 MB (build fails above)&lt;/td&gt;
&lt;td&gt;generates both&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://developers.facebook.com/docs/sharing/webmasters/images/" rel="noopener noreferrer"&gt;Facebook image guidelines&lt;/a&gt;, &lt;a href="https://www.linkedin.com/help/linkedin/answer/a521928" rel="noopener noreferrer"&gt;LinkedIn sharing image requirements&lt;/a&gt;, &lt;a href="https://developer.x.com/en/docs/x-for-websites/cards/overview/summary-card-with-large-image" rel="noopener noreferrer"&gt;X summary card with large image&lt;/a&gt; and the &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image" rel="noopener noreferrer"&gt;Next.js opengraph-image docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I use the free OG image generator?
&lt;/h2&gt;

&lt;p&gt;Open the tool, pick a template, type your title, subtitle and author, choose a font and three colors, then click Download PNG. The preview updates as you type and always renders at 1200×630. You can also copy the image URL to use directly in &lt;code&gt;og:image&lt;/code&gt;, or copy the Next.js code.&lt;/p&gt;

&lt;p&gt;Step by step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://tools.fabwaseem.com/t/og-image-generator" rel="noopener noreferrer"&gt;tools.fabwaseem.com/t/og-image-generator&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Choose one of six templates: &lt;strong&gt;Blog post&lt;/strong&gt; (title, dek and byline), &lt;strong&gt;Product / SaaS&lt;/strong&gt; (launch card with a mark), &lt;strong&gt;Minimal&lt;/strong&gt; (type and empty space), &lt;strong&gt;Gradient&lt;/strong&gt; (full-bleed color), &lt;strong&gt;Code snippet&lt;/strong&gt; (editor window) or &lt;strong&gt;Personal / profile&lt;/strong&gt; (name, role and portrait).&lt;/li&gt;
&lt;li&gt;Fill in the title, subtitle and author. For the product and profile templates you can add a logo or avatar URL (HTTPS only; PNG, JPEG, WebP or GIF up to 1.5 MB).&lt;/li&gt;
&lt;li&gt;Pick a font: Inter, Syne, Space Grotesk, Playfair Display, JetBrains Mono or Urbanist.&lt;/li&gt;
&lt;li&gt;Set the background, text and accent colors.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Download PNG&lt;/strong&gt;, &lt;strong&gt;Copy URL&lt;/strong&gt;, or &lt;strong&gt;Copy code&lt;/strong&gt; for an &lt;code&gt;opengraph-image.tsx&lt;/code&gt; file.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyv0glg9mcj1yhf7f71lb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyv0glg9mcj1yhf7f71lb.jpg" alt="Six 1200x630 OG image templates from the free generator: blog, product, minimal, gradient, code and profile" width="799" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The PNG is rendered when its URL is requested and is not stored as a file. Browsers and the CDN may cache the exact URL for a day, so the same card loads fast the next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I use the generated image URL directly in my meta tags?
&lt;/h2&gt;

&lt;p&gt;Yes. The Copy URL button gives you an absolute HTTPS link to &lt;code&gt;/api/og-tool&lt;/code&gt; with your settings in the query string, and that URL always returns a 1200×630 PNG. Paste it into &lt;code&gt;og:image&lt;/code&gt; and &lt;code&gt;twitter:image&lt;/code&gt;. For production sites, downloading the PNG or using the generated Next.js file keeps you independent of a third-party URL.&lt;/p&gt;

&lt;p&gt;Here is the shape of a generated URL (shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.fabwaseem.com/api/og-tool?template=blog&amp;amp;title=Ship+the+post+people+actually+share&amp;amp;subtitle=A+readable+1200%C3%97630+card&amp;amp;author=Waseem+Anjum&amp;amp;font=syne
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I generate dynamic OG images in Next.js?
&lt;/h2&gt;

&lt;p&gt;Add an &lt;code&gt;opengraph-image.tsx&lt;/code&gt; file to any App Router segment and default-export a function that returns an &lt;code&gt;ImageResponse&lt;/code&gt; from &lt;code&gt;next/og&lt;/code&gt;. Next.js turns that route into an image and injects the &lt;code&gt;og:image&lt;/code&gt;, &lt;code&gt;og:image:width&lt;/code&gt;, &lt;code&gt;og:image:height&lt;/code&gt;, &lt;code&gt;og:image:type&lt;/code&gt; and &lt;code&gt;og:image:alt&lt;/code&gt; tags into the page head automatically. No meta tags are written by hand.&lt;/p&gt;

&lt;p&gt;This is the pattern from the &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image" rel="noopener noreferrer"&gt;official Next.js docs&lt;/a&gt;, adapted for a blog post route with a dynamic &lt;code&gt;slug&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/blog/[slug]/opengraph-image.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ImageResponse&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/og&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;join&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:path&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getPost&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blog post cover&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&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;contentType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&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;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interSemiBold&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;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;assets/Inter-SemiBold.ttf&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
        &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;flexDirection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;column&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;justifyContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex-end&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0C0C0C&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#F5F5F5&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="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;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#8FB4C9&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          yoursite.com/blog
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Inter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;interSemiBold&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&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="mi"&gt;600&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;A few facts from the docs worth knowing before you ship it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The default export receives &lt;code&gt;params&lt;/code&gt; as a &lt;strong&gt;Promise&lt;/strong&gt;, so &lt;code&gt;await&lt;/code&gt; it (Next.js 15 and later).&lt;/li&gt;
&lt;li&gt;Generated images are &lt;strong&gt;statically optimized&lt;/strong&gt; at build time and cached, unless they use request-time APIs or uncached data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ImageResponse&lt;/code&gt; uses Satori and Resvg to turn JSX and CSS into a PNG. Only &lt;strong&gt;flexbox&lt;/strong&gt; and a subset of CSS work; &lt;code&gt;display: grid&lt;/code&gt; does not. Every &lt;code&gt;div&lt;/code&gt; with more than one child needs &lt;code&gt;display: 'flex'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://nextjs.org/docs/app/api-reference/functions/image-response" rel="noopener noreferrer"&gt;ImageResponse reference&lt;/a&gt; lists a &lt;strong&gt;500 KB maximum bundle size&lt;/strong&gt;, which includes your JSX, CSS, fonts and images. Fonts must be TTF, OTF or WOFF.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;twitter-image.tsx&lt;/code&gt; with the same code if you want a separate X image; otherwise X falls back to &lt;code&gt;og:image&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The free generator writes this file for you. Its output loads the chosen Google Font at render time with a small &lt;code&gt;loadFont&lt;/code&gt; helper, so you can paste it into a segment and run it without adding font files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which meta tags does an OG image need?
&lt;/h2&gt;

&lt;p&gt;At minimum you need &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:url&lt;/code&gt;, &lt;code&gt;og:type&lt;/code&gt; and &lt;code&gt;og:image&lt;/code&gt; with an absolute HTTPS URL, plus &lt;code&gt;twitter:card&lt;/code&gt; set to &lt;code&gt;summary_large_image&lt;/code&gt; for a large X card. Adding &lt;code&gt;og:image:width&lt;/code&gt;, &lt;code&gt;og:image:height&lt;/code&gt; and &lt;code&gt;og:image:alt&lt;/code&gt; helps platforms render the first share without waiting to download the image.&lt;/p&gt;

&lt;p&gt;If you are not on Next.js, paste this into your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"article"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your page title"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"One sentence that earns the click."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/blog/your-post"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/og/your-post.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:alt"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Short description of the image"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your page title"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"One sentence that earns the click."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/og/your-post.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Next.js App Router you can set the same values with the Metadata API. Set &lt;code&gt;metadataBase&lt;/code&gt; in your root layout so relative image paths resolve to absolute URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;metadataBase&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;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/og.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Example&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;summary_large_image&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 test and debug an OG image?
&lt;/h2&gt;

&lt;p&gt;Paste the page URL into the Facebook Sharing Debugger and the LinkedIn Post Inspector, then click scrape again to clear their cache. Open the &lt;code&gt;og:image&lt;/code&gt; URL in a browser to confirm it returns a 200 image. Finally, drop the link into Slack or Discord and check the unfurl. Most broken previews are a relative URL, a blocked crawler or a stale cache.&lt;/p&gt;

&lt;p&gt;Testing tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://developers.facebook.com/tools/debug/" rel="noopener noreferrer"&gt;Facebook Sharing Debugger&lt;/a&gt;: shows exactly which tags Facebook read and lets you force a re-scrape.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.linkedin.com/post-inspector/" rel="noopener noreferrer"&gt;LinkedIn Post Inspector&lt;/a&gt;: refreshes LinkedIn's cached preview.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl -I https://example.com/og.png&lt;/code&gt;: should return &lt;code&gt;200&lt;/code&gt; and &lt;code&gt;content-type: image/png&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl -s https://example.com/page | grep og:image&lt;/code&gt;: confirms the tag is in the server HTML, not added later by client JavaScript.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Common causes and fixes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No image at all&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;og:image&lt;/code&gt; is a relative path&lt;/td&gt;
&lt;td&gt;Use an absolute https URL; in Next.js set &lt;code&gt;metadataBase&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old image keeps showing&lt;/td&gt;
&lt;td&gt;Platform cache&lt;/td&gt;
&lt;td&gt;Re-scrape in the debugger; add a version query like &lt;code&gt;?v=2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works in production, not on localhost&lt;/td&gt;
&lt;td&gt;Crawlers cannot reach localhost&lt;/td&gt;
&lt;td&gt;Test on a preview deployment or a tunnel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image cropped badly on X&lt;/td&gt;
&lt;td&gt;Ratio far from 2:1 or text near the edges&lt;/td&gt;
&lt;td&gt;Use 1200×630 with 60 to 80 px padding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js build fails on the image&lt;/td&gt;
&lt;td&gt;File over 8 MB (OG) or 5 MB (Twitter)&lt;/td&gt;
&lt;td&gt;Compress, or generate with code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ImageResponse&lt;/code&gt; layout is broken&lt;/td&gt;
&lt;td&gt;Unsupported CSS such as grid&lt;/td&gt;
&lt;td&gt;Use flexbox and &lt;code&gt;display: 'flex'&lt;/code&gt; on parents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tag missing in page source&lt;/td&gt;
&lt;td&gt;Set in a client component&lt;/td&gt;
&lt;td&gt;Set metadata on the server (Metadata API or file convention)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Static OG image or dynamic OG image: which should I use?
&lt;/h2&gt;

&lt;p&gt;Use a static 1200×630 file for pages that rarely change, such as the home page, and dynamic OG images for content that grows, such as blog posts, docs and product pages. Dynamic images keep every share on-brand automatically, while a static file is the simplest and fastest option for a handful of pages.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Static file&lt;/th&gt;
&lt;th&gt;Dynamic (&lt;code&gt;next/og&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Free generator URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;Design once, drop in &lt;code&gt;app/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Write &lt;code&gt;opengraph-image.tsx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Copy one URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-page titles&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;td&gt;One URL per page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Home, landing pages&lt;/td&gt;
&lt;td&gt;Blogs, docs, catalogs&lt;/td&gt;
&lt;td&gt;Quick launches, side projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build impact&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Rendered at build or on request&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The generator sits in the middle: design visually, then either download a static PNG or copy the code to make it dynamic.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is this OG image generator really free?
&lt;/h3&gt;

&lt;p&gt;Yes. It runs in the browser with no account, no sign-up and no watermark on the PNG. You can download images, copy the image URL or copy the Next.js code as often as you like.&lt;/p&gt;

&lt;h3&gt;
  
  
  What size is an OG image in Next.js?
&lt;/h3&gt;

&lt;p&gt;The Next.js docs use &lt;code&gt;size = { width: 1200, height: 630 }&lt;/code&gt; in &lt;code&gt;opengraph-image.tsx&lt;/code&gt;, and &lt;code&gt;ImageResponse&lt;/code&gt; defaults to 1200×630. Image files must stay under 8 MB for &lt;code&gt;opengraph-image&lt;/code&gt; and 5 MB for &lt;code&gt;twitter-image&lt;/code&gt;, or the build fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need separate Twitter card images?
&lt;/h3&gt;

&lt;p&gt;Usually not. X reads &lt;code&gt;twitter:image&lt;/code&gt; first and falls back to &lt;code&gt;og:image&lt;/code&gt;. Add &lt;code&gt;twitter:card&lt;/code&gt; with the value &lt;code&gt;summary_large_image&lt;/code&gt; so X shows the large card. Create a separate &lt;code&gt;twitter-image&lt;/code&gt; only if you want a different design for X.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my OG image not showing?
&lt;/h3&gt;

&lt;p&gt;The most common reasons are a relative image URL, a page that blocks crawlers, a tag added by client-side JavaScript, or a cached old preview. Use an absolute https URL, check the server HTML and re-scrape in the Facebook Sharing Debugger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use WebP or SVG for OG images?
&lt;/h3&gt;

&lt;p&gt;Use PNG or JPG for the widest support. X accepts WEBP, but SVG is not supported by the major platforms, and the Next.js file convention accepts JPG, PNG and GIF for static image files. The free generator always outputs PNG.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does an OG image help SEO?
&lt;/h3&gt;

&lt;p&gt;It does not directly change Google rankings, but it improves click-through when links are shared on social networks and in chat apps. For AI search visibility, pair it with good metadata and structured data, as covered in my &lt;a href="https://www.fabwaseem.com/blog/nextjs-seo-ai-search" rel="noopener noreferrer"&gt;Next.js SEO for AI search guide&lt;/a&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Design every OG image at &lt;strong&gt;1200×630 (1.91:1)&lt;/strong&gt;, under 5 MB, with 60 to 80 px of padding.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;og:image&lt;/code&gt; must be an &lt;strong&gt;absolute HTTPS URL&lt;/strong&gt;; in Next.js, set &lt;code&gt;metadataBase&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In Next.js, an &lt;code&gt;opengraph-image.tsx&lt;/code&gt; with &lt;code&gt;ImageResponse&lt;/code&gt; from &lt;code&gt;next/og&lt;/code&gt; generates the image and the meta tags for you. Use flexbox only.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;twitter:card&lt;/code&gt; = &lt;code&gt;summary_large_image&lt;/code&gt;; X falls back to &lt;code&gt;og:image&lt;/code&gt; for the picture.&lt;/li&gt;
&lt;li&gt;Always re-scrape in the Facebook Sharing Debugger and LinkedIn Post Inspector after changing an image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to make one? &lt;strong&gt;&lt;a href="https://tools.fabwaseem.com/t/og-image-generator" rel="noopener noreferrer"&gt;Create your free OG image now&lt;/a&gt;&lt;/strong&gt;: pick a template, download the PNG or copy the Next.js code. For more of what I build, see my &lt;a href="https://www.fabwaseem.com/projects" rel="noopener noreferrer"&gt;projects&lt;/a&gt; or head back to the &lt;a href="https://www.fabwaseem.com/" rel="noopener noreferrer"&gt;home page&lt;/a&gt;.&lt;/p&gt;




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

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
      <category>opengraph</category>
    </item>
  </channel>
</rss>
