<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
  xmlns:atom="http://www.w3.org/2005/Atom"
  xmlns:content="http://purl.org/rss/1.0/modules/content/"
  xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Agnel Nieves - Next.js</title>
    <link>https://agnelnieves.com/blog/tag/nextjs</link>
    <description>Blog posts on Next.js by Agnel Nieves.</description>
    <language>en-US</language>
    <lastBuildDate>Fri, 18 Sep 2026 17:56:57 GMT</lastBuildDate>
    <atom:link href="https://agnelnieves.com/blog/tag/nextjs/feed.xml" rel="self" type="application/rss+xml" />
    
    <item>
      <title><![CDATA[I fixed my site for agents by hand. Then Vercel shipped a scoreboard]]></title>
      <link>https://agnelnieves.com/blog/measuring-how-agentic-my-site-is-with-is-agentic</link>
      <guid isPermaLink="true">https://agnelnieves.com/blog/measuring-how-agentic-my-site-is-with-is-agentic</guid>
      <description><![CDATA[I spent a session making this site readable by agents, then ran it through is-agentic.com, the new agent-readiness tool from Vercel. The checks it grades are the exact things I'd just shipped.]]></description>
      <content:encoded><![CDATA[<p>Earlier this week I ran an agent-readiness audit on this site and spent a session fixing what it flagged. Structured JSON errors on every API route, an OpenAPI spec at <code>/openapi.json</code>, a 404 that answers agents instead of showing them a styled dead end, and content negotiation so any page returns clean markdown when something sends <code>Accept: text/markdown</code>. Real fixes, verified against a running server, no scoreboard to check them against.</p>
<p>Then Vercel shipped the scoreboard.</p>
<h2>What is-agentic.com does</h2>
<p><a href="https://is-agentic.com">is-agentic.com</a> measures how well an agent can discover, read, and use a public website. The scan runs on <a href="https://ora.ai">Ora</a>&#39;s agent-readiness research, and Vercel wraps it in a public report with observed agent journeys and a fix-it prompt.</p>
<TwitterEmbed>
<blockquote className="twitter-tweet" data-theme="dark"><p lang="en" dir="ltr">Introducing is-agentic.com, a tool to measure how well agents can read your site. Backed by <a href="https://twitter.com/oradotai">@oradotai</a>'s research, you can run:<br/><br/>▪︎ Audits with 100+ checks<br/>▪︎ Visualizations of agents using your site<br/>▪︎ One-click prompts to fix problems<br/>▪︎ A CLI for agents</p>&mdash; Vercel (@vercel_dev) <a href="https://twitter.com/vercel_dev/status/2090857388924661915">August 20, 2026</a></blockquote>
</TwitterEmbed><p>The scoring is applicability-aware, which is the part I care about. Essential checks share an 80-point pool, recommended checks share 20, and emerging signals can add a capped 5-point bonus without ever becoming a requirement. Checks that do not apply to your site get excluded instead of counting against you. So a static portfolio and a public API surface get graded on what they actually are, not against one universal checklist. The <a href="https://is-agentic.com/methodology">methodology page</a> spells out the model, and it is worth reading before you argue with a number.</p>
<h2>Running it</h2>
<p>There is a CLI, so I did not have to open a browser:</p>
<pre><code class="language-sh">npx is-agentic agnelnieves.com
</code></pre>
<p>It returns a stored report if one exists, or kicks off a scan and waits. Add <code>--json</code> and you get the raw API response with structured errors, which is the shape an agent wants when it runs this as a step in its own loop rather than reading a terminal.</p>
<p>That detail is the whole point. The report API answers <code>Accept: text/markdown</code>, follows RFC 9457 for its error bodies, and exposes a read-only MCP server at <code>https://is-agentic.com/mcp</code> with three tools for pulling a report, the methodology, and the integration docs. The tool that grades your site for agent-readiness is built to be read by agents. Which is the joke Chris made better than I can:</p>
<TwitterEmbed>
<blockquote className="twitter-tweet" data-theme="dark"><p lang="en" dir="ltr">Best part: is-agentic.com is itself agentic<br/><br/>Your agent can use it to make your site more agentic</p>&mdash; Chris Tate (@ctatedev) <a href="https://twitter.com/ctatedev/status/2090865331862446088">August 20, 2026</a></blockquote>
</TwitterEmbed><h2>The checks lined up with the work</h2>
<p>Going through the essential group felt like reading back my own commit list. The status-code and error-shape checks map to the JSON error helper I added across the API. The discovery checks map to the OpenAPI spec and the <code>llms.txt</code> entry that now points at it. The markdown-negotiation check is the <code>Accept: text/markdown</code> proxy and the <code>Vary</code> header I set on every negotiated route. The 404 check is the <code>not-found</code> page that returns a real 404 with links back to <code>llms.txt</code>, the sitemap, and the OpenAPI spec.</p>
<p>I did not build those things because a tool told me to. I built them from a hand audit, then a tool showed up that grades the same fundamentals. That is the useful signal here: the checks are not exotic agent tricks, they are ordinary web hygiene an agent happens to depend on. Accurate status codes. HTML that means something. Docs that describe what a service can do. An agent needs those for the same reason a screen reader does.</p>
<h2>Where I&#39;d send you first</h2>
<p>If you own a public site, run the CLI once and read the essential failures before anything else. The recommended and bonus tiers are where you chase points; the essential pool is where an agent actually gets stuck. Then hand the fix-it prompt to whatever coding agent you use and let it close the gaps, which is the agentic-fixing-agentic loop the announcement is pointing at.</p>
<p>The <a href="https://is-agentic.com/docs">docs</a> cover the report API, rate limits, and the MCP server. The <a href="https://is-agentic.com/about">about page</a> is honest about the limits: a score is a public snapshot, not a certification, and automated checks can be wrong, so read the evidence in context. I like that they said so.</p>
<p>I went in expecting to defend my recent work against a stricter grader. I came out with a second opinion that agreed with the audit I already trusted, plus an API I can wire into the CLI behind <code>ssh agnelnieves.sh</code> later. Good week for the robots reading this.</p>
]]></content:encoded>
      <pubDate>Fri, 21 Aug 2026 00:00:00 GMT</pubDate>
      <author>agnel@agnelnieves.com (Agnel Nieves)</author>
      <dc:creator><![CDATA[Agnel Nieves]]></dc:creator>
      <category>AI</category>
      <category>Agents</category>
      <category>Vercel</category>
      <category>Next.js</category>
      <category>Web Development</category>
    </item>
    <item>
      <title><![CDATA[Google Thought My Archive Was the Homepage]]></title>
      <link>https://agnelnieves.com/blog/google-thought-my-archive-was-the-homepage</link>
      <guid isPermaLink="true">https://agnelnieves.com/blog/google-thought-my-archive-was-the-homepage</guid>
      <description><![CDATA[Search Console said duplicate page with a proper canonical. Six of my own URLs were pointing at /. Here is the Next.js leak, and why I flipped four reprints back to Promptway.]]></description>
      <content:encoded><![CDATA[<p>Search Console handed me a status I already knew by name: <a href="https://support.google.com/webmasters/answer/7440203#duplicate_page_with_proper_canonical_tag">Duplicate page with proper canonical tag</a>. I assumed it was the four reprints I had pointed at this site from <a href="https://promptway.com">Promptway</a>. That was half the story. The other half was worse.</p>
<p>I curled the live HTML. <code>promptway.com/blog</code> said its canonical was <code>https://promptway.com</code>. So did <code>/authors</code>. So did <code>/authors/agnel-nieves</code>, <code>/authors/diego-ferraro</code>, and the rest of the writer pages. Google was doing exactly what I asked: treating the archive and the writer pages as copies of the homepage, and refusing to index them.</p>
<p>This is the sequel to <a href="https://promptway.com/blog/from-invisible-to-indexed">From Invisible to Indexed</a>. That piece was about the domain serving the wrong product. This one is about the product serving the wrong address on its own pages.</p>
<h2>What a &quot;proper&quot; canonical actually means</h2>
<p>The status is not an error. Google is saying: this URL has a canonical pointing somewhere else, we agree, we will not index this one. When that is a <code>/blog/slug.md</code> alternate, or a trailing-slash redirect, you want that outcome. When it is <code>/blog</code>, the page you put in the sitemap as the archive, you do not.</p>
<p>I had done the sitemap work. I had <code>index, follow</code> on those routes. None of that matters if the <code>&lt;link rel=&quot;canonical&quot;&gt;</code> says &quot;the real page is <code>/</code>.&quot;</p>
<p>The Next.js metadata docs are explicit about this. Metadata objects <a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata">shallow-merge down the tree</a>, and nested fields like <code>alternates</code> get replaced by the last segment that defines them. They do not get replaced by a page that never mentions them. A root layout with <code>alternates.canonical: &quot;/&quot;</code> becomes the canonical of every child that forgets to set its own.</p>
<p>I had set self-canonicals on <code>/about</code>, <code>/tools</code>, <code>/subscribe</code>, the pillar pages, and every article. I had not set them on <code>/blog</code>, <code>/authors</code>, or <code>/authors/[slug]</code>. Those three inherited the homepage. Production HTML confirmed it.</p>
<p><code>og:url</code> had the same leak. The root layout set <code>openGraph.url: &quot;/&quot;</code>, and pages that never defined their own <code>openGraph</code> object inherited the homepage as the social URL. Google treats <code>og:url</code> as a secondary canonical hint. I pulled that field off the root layout too.</p>
<h2>The four reprints pointing the wrong way</h2>
<p>The Search Console bucket also held four articles I had published first here and then reprinted on Promptway with <code>canonical</code> pointing back:</p>
<ul>
<li><a href="https://promptway.com/blog/from-invisible-to-indexed">From Invisible to Indexed</a></li>
<li><a href="https://promptway.com/blog/optimizing-your-site-for-ai-agents">Optimizing Your Site for AI Agents and LLMs</a></li>
<li><a href="https://promptway.com/blog/optimizing-for-ai-search-in-2026">Optimizing for SEO, AEO, GEO, and AI Search in 2026</a></li>
<li><a href="https://promptway.com/blog/connecting-claude-to-google-ads-and-ga4-via-mcp">Connecting Claude to Google Ads and GA4 via MCP</a></li>
</ul>
<p>That is the HackerNoon pattern. The original keeps the credit. I wrote <a href="https://promptway.com/blog/getting-your-writing-seen-beyond-your-own-site">Getting Your Writing Seen Beyond Your Own Site</a> arguing the opposite for the publication: Promptway is the hub, everywhere else is a spoke. I had applied the rule everywhere except to my own reprints.</p>
<p>So I flipped them. Promptway now self-canonicalizes. The copies on this site declare <code>canonical</code> at the Promptway URL, drop out of this sitemap, and show a visible &quot;Originally published on Promptway&quot; line in the header. <code>llms.txt</code> here lists the Promptway URL too, so agents that land on this domain still get pointed at the hub.</p>
<p>If you keep a reprint with an off-site canonical, do not submit it in your sitemap. Submitting it says &quot;index this URL.&quot; The page itself says &quot;index that other URL.&quot; Search Console will file the contradiction under the same status that started this post.</p>
<h2>The fix, in the order I would do it again</h2>
<ol>
<li><strong>Do not set <code>canonical</code> or <code>og:url</code> on the root layout.</strong> Keep feed auto-discovery there if you want. Put the homepage canonical on <code>app/page.tsx</code>.</li>
<li><strong>Give every indexable route a self-canonical.</strong> Archive, writers index, writer pages, pillars, tools, about, subscribe. If a page is in the sitemap, it needs its own address.</li>
<li><strong>Curl the rendered HTML.</strong> Do not trust the source file. I used Googlebot as the user agent and grepped for <code>rel=&quot;canonical&quot;</code>. The leak was obvious in one pass.</li>
<li><strong>Decide the hub before you syndicate.</strong> If Promptway is the home copy, this domain points there. If the personal essay is the home copy, do not put Promptway in the sitemap for that slug.</li>
<li><strong>Give markdown alternates a <code>Link: rel=&quot;canonical&quot;</code> header</strong> pointing at the HTML article. I want <code>/blog/&lt;slug&gt;.md</code> crawlable for agents. I do not want it competing as a search result.</li>
</ol>
<p>The www, http, trailing-slash, and <code>*.vercel.app</code> copies were already 308ing to the apex. Those were fine. The bug was inside the HTML I was proud of.</p>
<h2>What I am watching now</h2>
<p>After deploy I will request indexing on <code>/blog</code>, <code>/authors</code>, and each writer page on Promptway. The four flipped articles get the same request there. On this site, those four URLs should move into &quot;Duplicate page with proper canonical tag&quot; and stay there. That is the correct status for a spoke.</p>
<p>I do not have the recrawl numbers yet. I will update this if Google disagrees with the new tags. Last time the domain pointed at the wrong product, the machines believed the tag. I am betting they will believe it this time too.</p>
]]></content:encoded>
      <pubDate>Mon, 17 Aug 2026 00:00:00 GMT</pubDate>
      <author>agnel@agnelnieves.com (Agnel Nieves)</author>
      <dc:creator><![CDATA[Agnel Nieves]]></dc:creator>
      <category>SEO</category>
      <category>AEO</category>
      <category>Canonical</category>
      <category>Search Console</category>
      <category>Next.js</category>
      <category>Indexing</category>
    </item>
    <item>
      <title><![CDATA[Why my Personal Site Is Also a Monorepo Now]]></title>
      <link>https://agnelnieves.com/blog/why-my-personal-site-is-also-a-monorepo-now</link>
      <guid isPermaLink="true">https://agnelnieves.com/blog/why-my-personal-site-is-also-a-monorepo-now</guid>
      <description><![CDATA[I turned my personal site into a monorepo because every side project I start wants to live somewhere. Here's the thinking, and what I'm planning next.]]></description>
      <content:encoded><![CDATA[<p>This is a note to myself as much as anyone else. A monorepo on a one-person site sounds like overkill. I went there anyway. Here is the honest reason, and what I&#39;m planning to do with it.</p>
<h2>The real reason</h2>
<p>I keep starting side projects. Some of them die. Others few stick. The ones that stick almost always want to live near my personal site, because they share the same brand, the same writing, the same identity. The terminal CLI behind <code>ssh agnelnieves.sh</code> is the obvious example. It reads from this site&#39;s API, it carries my voice, it is the same project in a different shape. But sometimes they have nothing to do with React or Tailwind, and it should not have to pretend to.</p>
<p>So the actual problem the monorepo solves for me is not Google scale or Vercel scale or any other scale. I just wanted a single place where my personal stuff lives, and I want each piece to stay clean and separate without me having to invent a new repo every time an idea shows up. Compartmentalize. Separate the concerns. Keep things tidy. Make the next thing cheap to start.</p>
<p>Before this week, the layout was a Next.js app with a Rust CLI in a sibling folder, sharing one <code>package.json</code> that did not care about it. That worked until I started writing down all the other things I want to ship from here, and the folder names started overlapping.</p>
<h2>What I&#39;m planning to add</h2>
<p>These are the side projects that are already drawn on a napkin somewhere:</p>
<ul>
<li>An automatic resume generator. The data lives here already (work history, projects, links). I want to feed it into a Resume builder package that renders to PDF, to a <code>/resume</code> page, and to a JSON endpoint for the CLI, all from one source. Maintaining up to date resumes are a huge pain of mine, so one of those things which is worth automating.</li>
<li>A social media post pipeline. Take a blog post I just published, generate the X thread, the LinkedIn version, and the headline-friendly image, all from code. Tag what posted where so nothing gets sent twice.</li>
<li>More CLI commands. The Rust binary is going to grow. Just for fun. </li>
<li>Probably something with AI agents. Some helper that drafts things based on my writing history.</li>
<li>Games, ideas, whatever else shows up.</li>
</ul>
<p>I do not want to make one of those a folder inside <code>src/</code>. Not because it would not work, but because they are not part of the Next.js app and they should not have to be. They are their own thing. They should have their own dependencies, their own build, their own deployments if they need one. A monorepo was the cheapest way I know to give each of them its own room without making me run <code>bun init</code> in a new GitHub repo every time and then forgetting which one had the latest version of my bio.</p>
<h2>What the layout looks like now</h2>
<pre><code>.
├── apps/
│   ├── web/         the Next.js site you are reading
│   └── cli/         the Rust binary behind ssh agnelnieves.sh
└── packages/
    └── tsconfig/    shared TypeScript presets, room for more
</code></pre>
<p><code>apps/</code> is for things I ship. <code>packages/</code> is for things those apps share. Right now there is only one shared package, on purpose. I do not want to invent abstractions before they have a second consumer. The day the resume generator and the social media tool both want the same &quot;load my blog posts&quot; helper, I will pull it into <code>packages/content</code> and let both of them depend on it. Until that day, it stays inside the Next.js app.</p>
<p>The tooling underneath is the same Rust-first stack I have written about before. Bun for the lockfile and the workspaces. Turborepo for the task graph. Biome and oxlint for the linters. Cargo for the Rust side. None of that is new. What is new is that they all sit inside one <code>turbo run</code>, and the cache means that editing a blog post does not re-typecheck the CLI, and editing the CLI does not rebuild the site. That is the whole point.</p>
<p>If you want the numbers (I do, because I like metrics): a clean rebuild with nothing changed runs in 67 milliseconds across both apps. That is the cache talking. The first cold build is about 80 seconds, mostly because the Rust binary is link-time optimized. Subsequent commits are nearly free. Pre-commit hooks stay out of my way. That is the day-to-day payoff.</p>
<h2>What I almost talked myself out of</h2>
<p>A few times during the migration I thought &quot;this is too much for a personal site.&quot; It would have been faster to keep going with the old layout for another year. The Rust CLI was already working. Nothing was actually broken.</p>
<p>What flipped me back was looking at the list of side projects I had stopped because they did not have a home. Three of them were dead by the same path: I wanted to ship a thing, I did not want to create a new repo for it, I tried to wedge it into this site as a folder, it felt wrong, I lost interest. The friction was not the code. The friction was that I did not have a clean place to put it. Sometimes my ideas/side projects are just experimental. Just as a way to do something fun or different.</p>
<p>A monorepo is, for me, a way of pre-committing to that clean place existing. The next time I want to start a small thing, it goes in <code>apps/whatever-it-is</code> or <code>packages/whatever-it-is</code>. I do not have to make any decisions about hosting, repo naming, or where it should live. The decision is already made.</p>
<h2>Things that broke, briefly, so future me knows</h2>
<p>There were three real hitches. I am writing them down because I will hit them again.</p>
<p>The first was a hoisting thing. The Next.js code imported <code>viem</code>, but <code>viem</code> was not in the <code>package.json</code>. It had been getting pulled in transitively through another package, and the flat <code>node_modules</code> made it look like it was installed. The minute I turned on bun workspaces, bun switched to isolated installs, and <code>viem</code> was no longer reachable from where the code expected it. The fix is one line. The lesson is that workspaces will surface every latent assumption your old install had been quietly covering for. Treat them as bugs the workspaces helped you find, not as new problems the workspaces caused.</p>
<p>The second was Vercel. Next.js 16 plus bun&#39;s isolated install plus Vercel&#39;s monorepo defaults collide in a way that the docs do not warn you about. The build fails locally and on Vercel until you set the project&#39;s Root Directory to <code>apps/web</code> in the Vercel dashboard and pin <code>turbopack.root</code> and <code>outputFileTracingRoot</code> in <code>next.config.ts</code>. The Vercel CLI cannot change Root Directory for you. You have to click it in the dashboard. I spent an embarrassing amount of time discovering this.</p>
<p>The third was that <code>vercel link</code> from inside <code>apps/web/</code> overwrites your <code>.env.local</code> with whatever the Vercel project has. If you had local-only secrets in there, they are gone. Back up first.</p>
<p>If you ever do this migration on your own site and want the working version of next.config.ts, the relevant bit lives in the actual repo and is referenced in <a href="/TOOLCHAIN.md">the toolchain doc</a>.</p>
<h2>What I want this to be</h2>
<p>I want this site to be the thing I tinker with when I am bored, without it feeling like I am about to make a mess. I want every side project to have a room. I want the resume to update itself the next time I add a project. I want the CLI to grow without dragging the website along with it. I want to add a third runnable thing in two months without thinking about it for more than five minutes.</p>
<p>A monorepo is just the shape that makes that possible without making me feel like I am building infrastructure when I should be writing.</p>
<p>If you are one person and you keep starting things that go nowhere because they do not have a home, I would genuinely consider doing this. The migration is a single afternoon. The rest of the time, it stays out of your way and just lets you start the next thing.</p>
<h2>Related</h2>
<ul>
<li><a href="/blog/rust-owns-the-javascript-toolchain-in-2026">Rust owns the JavaScript toolchain in 2026</a></li>
<li><a href="/blog/building-a-terminal-portfolio-you-can-ssh-into">Building a terminal portfolio you can SSH into</a></li>
<li><a href="https://turborepo.dev/docs">Turborepo</a></li>
<li><a href="https://www.remotion.dev/">Remotion</a> (how <code>packages/video</code> at basement works)</li>
</ul>
]]></content:encoded>
      <pubDate>Fri, 05 Jun 2026 00:00:00 GMT</pubDate>
      <author>agnel@agnelnieves.com (Agnel Nieves)</author>
      <dc:creator><![CDATA[Agnel Nieves]]></dc:creator>
      <category>Monorepo</category>
      <category>Turborepo</category>
      <category>Bun</category>
      <category>Next.js</category>
      <category>Rust</category>
      <category>Personal</category>
    </item>
    <item>
      <title><![CDATA[Optimizing a personal site for SEO, AEO, GEO, and AI search in 2026]]></title>
      <link>https://agnelnieves.com/blog/optimizing-a-personal-site-for-ai-search-in-2026</link>
      <guid isPermaLink="true">https://agnelnieves.com/blog/optimizing-a-personal-site-for-ai-search-in-2026</guid>
      <description><![CDATA[What I had in place, what an honest Lighthouse plus Search Console audit uncovered, and the fixes that took my homepage from 14.5 MB to 1 MB without losing the look.]]></description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Google published its <a href="https://developers.google.com/search/docs/fundamentals/ai-optimization-guide">Optimizing your website for generative AI features on Google Search</a> guide. The headline is short: there is no special markup for AI Overviews or AI Mode. They use the same index as classic Search. If your site is fast, indexable, well structured, and useful, it is also AI search ready.</p>
<p>I audited this site against that guide, ran Lighthouse via the Node CLI, and patched what came back. Mobile homepage, before and after:</p>
<table>
<thead>
<tr>
<th>Category</th>
<th align="right">Before</th>
<th align="right">After</th>
</tr>
</thead>
<tbody><tr>
<td>Performance</td>
<td align="right">63</td>
<td align="right">73</td>
</tr>
<tr>
<td>Accessibility</td>
<td align="right">95</td>
<td align="right">100</td>
</tr>
<tr>
<td>Best Practices</td>
<td align="right">100</td>
<td align="right">100</td>
</tr>
<tr>
<td>SEO</td>
<td align="right">100</td>
<td align="right">100</td>
</tr>
<tr>
<td>Agentic Browsing</td>
<td align="right">67</td>
<td align="right">100</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>Metric</th>
<th align="right">Before</th>
<th align="right">After</th>
</tr>
</thead>
<tbody><tr>
<td>LCP</td>
<td align="right">43.2 s</td>
<td align="right">5.9 s</td>
</tr>
<tr>
<td>FCP</td>
<td align="right">3.3 s</td>
<td align="right">2.7 s</td>
</tr>
<tr>
<td>Speed Index</td>
<td align="right">6.2 s</td>
<td align="right">3.6 s</td>
</tr>
<tr>
<td>Total page weight</td>
<td align="right">14,561 KiB</td>
<td align="right">1,008 KiB</td>
</tr>
</tbody></table>
<p>Four of five Lighthouse categories at 100. Page weight down 93%. LCP down 86%.</p>
<p>If you want the executable version of this post, the <a href="/guides/auditing-and-optimizing-for-ai-search.md">companion guide</a> opens with an agent prompt block so you can paste it into Claude, Cursor, or any other coding agent and have it walk you through the same audit on your own site.</p>
<h2>SEO vs AEO vs GEO: what the letters actually mean</h2>
<table>
<thead>
<tr>
<th>Term</th>
<th>Stands for</th>
<th>Where it shows up</th>
</tr>
</thead>
<tbody><tr>
<td>SEO</td>
<td>Search Engine Optimization</td>
<td>Google and Bing organic results</td>
</tr>
<tr>
<td>AEO</td>
<td>Answer Engine Optimization</td>
<td>AI Overviews, Perplexity, Copilot, featured snippets</td>
</tr>
<tr>
<td>GEO</td>
<td>Generative Engine Optimization</td>
<td>ChatGPT, Claude, Gemini citations</td>
</tr>
</tbody></table>
<p>These are not three different stacks. They are layers. Google&#39;s own guide confirms that AI Overviews and AI Mode rank from the same index as classic Search. AEO is SEO plus structured answers. GEO is AEO plus machine readable discovery for engines outside Google&#39;s ecosystem.</p>
<p>There is no AI specific markup that flips a switch. The work is foundational: be fast, be indexable, be unambiguous about what you are.</p>
<h2>What was already in place</h2>
<p>Before this session, the site already had the following baked into the Next.js metadata API and route handlers:</p>
<ul>
<li><strong>Per page metadata.</strong> Title, description, canonical, OpenGraph, and Twitter card on every route. The root layout uses a <code>title.template</code> so child pages don&#39;t repeat the brand suffix.</li>
<li><strong>Structured data (JSON-LD).</strong> <code>WebSite</code> and <code>Person</code> on the root, <code>BlogPosting</code> and <code>BreadcrumbList</code> on blog posts, <code>CreativeWork</code> on project pages.</li>
<li><strong><code>sitemap.xml</code></strong> generated from blog posts, projects, decks, and tags via <code>src/app/sitemap.ts</code>.</li>
<li><strong><code>robots.txt</code></strong> with allow rules for GPTBot, Google-Extended, ChatGPT-User, Applebot-Extended, anthropic-ai, ClaudeBot, PerplexityBot, Bytespider, and cohere-ai. The same file blocks my x402 paywall endpoints under <code>Disallow: /api/x402/</code> so they stay out of the index.</li>
<li><strong>Three feed formats.</strong> RSS, Atom, and JSON Feed. Different syndicators and AI ingestion paths favor different formats.</li>
<li><strong><code>llms.txt</code> and <code>llms-full.txt</code> routes.</strong> Google has now publicly said it does not read these. Other engines may. The routes are cheap to keep.</li>
<li><strong>IndieAuth, webmention, and h-entry microformats.</strong> The indie web identity signals that compose into a single content graph across small sites.</li>
<li><strong>E-E-A-T signals.</strong> <code>sameAs</code> links pointing at my X, LinkedIn, and GitHub. Bylines. Reading time. Last modified timestamps. Tags. Author photo via OG image.</li>
</ul>
<p>A separate <a href="/guides/ai-optimization-guide.md">AI optimization guide</a> already documents those routes. This post is the audit companion: what to do after that foundation is in place.</p>
<h2>What Google&#39;s guide says NOT to do</h2>
<p>The guide spends real time on what to skip. Worth flagging up front, because the urge to over engineer for AI search is real:</p>
<ul>
<li><strong>No AI specific markup.</strong> Google does not read <code>llms.txt</code>, special meta tags, &quot;AI friendly schema,&quot; or any other invented marker.</li>
<li><strong>No content chunking.</strong> Don&#39;t break paragraphs into Q&amp;A shards &quot;for AI parsers.&quot;</li>
<li><strong>No content rewriting.</strong> Don&#39;t tone down your prose to sound machine friendly. Write for humans.</li>
<li><strong>Don&#39;t over rely on structured data.</strong> Use it where it earns rich result formats. Don&#39;t bolt FAQ schema on every page hoping for a citation lift.</li>
<li><strong>Don&#39;t pursue inauthentic mentions.</strong> Backlink for AI citation is the new keyword stuffing.</li>
</ul>
<p>Most of the foundation list above sits inside what Google considers SEO already, even though some of it (microformats, IndieAuth, multiple feed formats) leans more web of data than ranking.</p>
<h2>The discoverability fixes</h2>
<h3>Google Search Console verification via DNS TXT</h3>
<p>The site was sending <code>verification: {}</code> in the root metadata. Google&#39;s guide flags Search Console verification as the one explicit must do. The wrinkle: my domain&#39;s nameservers point at Vercel, not at the registrar&#39;s cPanel DNS panel where an old TXT record had been sitting unnoticed for months. A quick <code>dig +short TXT agnelnieves.com</code> returned nothing for that record, which confirmed the registrar panel was a dead config.</p>
<p>Added the verification TXT in Vercel&#39;s DNS dashboard, then verified the <strong>Domain</strong> property in Search Console. Domain property covers apex, www, http, https, and every subdomain in one shot, so I removed the redundant <code>https://www.agnelnieves.com/</code> URL prefix property right after.</p>
<h3>Removed the inline <code>&lt;script type=&quot;text/llms.txt&quot;&gt;</code></h3>
<p>Old habit from a year ago when llms.txt was being floated as a real standard. Google has now confirmed it does not read it. The route at <code>/llms.txt</code> stays for other engines; the duplicate inline block in <code>&lt;head&gt;</code> is gone. Smaller HTML, less to argue about.</p>
<h3>Bing IndexNow ping on blog content changes</h3>
<p>Google ignores IndexNow. Bing and Yandex run it, and Bing powers ChatGPT&#39;s web search, Copilot, and DuckDuckGo. For a personal site that publishes occasionally, this is the difference between &quot;indexed within hours&quot; and &quot;indexed within days.&quot;</p>
<p>Three pieces:</p>
<ol>
<li>A key file at <code>/public/&lt;32-char-key&gt;.txt</code> so api.indexnow.org can verify ownership at the apex.</li>
<li>A <code>scripts/indexnow.mjs</code> that reads the push&#39;s git diff, filters to published blog posts, and POSTs the changed URLs.</li>
<li>A <code>.github/workflows/indexnow.yml</code> that only triggers when files under <code>src/content/blog/**</code> change, so code only pushes don&#39;t fire it.</li>
</ol>
<p>The whole thing is about 90 lines and is a no-op when content hasn&#39;t changed.</p>
<h2>The accessibility fixes</h2>
<h3>Multiple <code>&lt;h1&gt;</code> regressions</h3>
<p>A third party SEO scan reported &quot;more than one h1 tag, 7 instances.&quot; The h1 was creeping in from places that should have been h2 or h3:</p>
<ul>
<li><code>ProjectCard.tsx</code> rendered each project title as h1. Pages that list projects had 1 + N h1s.</li>
<li>Deck title slides used h1 even though the deck page already owned the page h1.</li>
<li>The MDX components mapping rendered markdown <code>#</code> as h1, which would silently re-introduce the bug when any new blog post started with <code># Heading</code>.</li>
<li>An unused <code>LandingIllustration</code> had a leftover h1.</li>
</ul>
<p>All demoted in one commit. The MDX one is load bearing because it prevents the bug from recurring.</p>
<h3>Missing alt text on the dither shader</h3>
<p>The site has a custom canvas dithering effect over photos. Its component was wrapping <code>&lt;img alt=&quot;&quot;&gt;</code> (empty alt, decorative) inside a <code>&lt;div role=&quot;img&quot; aria-label={alt}&gt;</code>. Screen readers handle this correctly because the outer div has the label, but strict SEO scanners only inspect the <code>&lt;img alt&gt;</code> and reported 10 missing alt instances across pages. Fix: put the real alt on the <code>&lt;img&gt;</code>, drop the wrapper&#39;s <code>role</code> and <code>aria-label</code> to avoid double announcement. One change, every page benefits.</p>
<h3>Logo link missing <code>aria-label</code></h3>
<p><code>&lt;Link href=&quot;/&quot;&gt;&lt;Logo /&gt;&lt;/Link&gt;</code> in <code>MainNav.tsx</code> rendered an SVG with no text node, so the link had no accessible name. That was the single root cause of both:</p>
<ul>
<li><code>link-name</code> failing in Accessibility (-5 points).</li>
<li><code>agent-accessibility-tree</code> failing in Agentic Browsing (-33 points), Lighthouse&#39;s new category for AI agent compatibility.</li>
</ul>
<p>Added <code>aria-label=&quot;Home&quot;</code>. Both scores went to 100 in the next audit.</p>
<h2>The performance fixes</h2>
<h3>The Lighthouse mobile audit, baseline</h3>
<p>Run via the Node CLI:</p>
<pre><code class="language-bash">bunx lighthouse@latest https://agnelnieves.com/ \
  --output=json --output-path=./home-mobile.json \
  --quiet --chrome-flags=&quot;--headless=new --no-sandbox&quot; \
  --form-factor=mobile
</code></pre>
<p>First run, mobile, homepage: Performance 63, LCP 43.2 s, total weight 14,561 KiB.</p>
<p>A 43 second LCP on a portfolio site is not subtle. The top 15 heaviest requests pointed at it: a 3.2 MB MP3 of background music and 14 raw JPEGs averaging 700 KB each.</p>
<h3>Lazy init the background music</h3>
<p><code>PowerButton.tsx</code> was creating <code>new Audio(&quot;/sounds/Future We Forgot.mp3&quot;)</code> inside a <code>useEffect</code>, which triggers a full preload on mount even if the user never clicks the button. PowerButton renders inside the LogoEngraving bar in every page&#39;s chrome, so 3.3 MB of audio loaded on every route.</p>
<p>Lazy instantiate on the first toggle to &quot;on&quot; instead of on mount. The Audio object only exists if the user opts in.</p>
<h3>Re-encode the MP3 itself</h3>
<p>The MP3 was 187 kbps stereo with an embedded 360x360 cover art JPEG riding along. The track plays at <code>audio.volume = 0.025</code> (effectively inaudible) when toggled on. There is no quality budget here.</p>
<pre><code class="language-bash">ffmpeg -y -i &quot;Future We Forgot.mp3&quot; -vn -map_metadata -1 \
  -ac 1 -ar 44100 -b:a 64k -codec:a libmp3lame \
  &quot;Future We Forgot.optimized.mp3&quot;
</code></pre>
<p>3.3 MB to 1.1 MB. 66% reduction. <code>-vn</code> strips the cover art, <code>-map_metadata -1</code> drops the ID3 tags. At 2.5% volume the difference is inaudible.</p>
<h3>Convert the photo JPEGs to AVIF</h3>
<p>14 photos in <code>/public/images/an/</code> cycling on the homepage avatar grid at 500 ms intervals, sized 500 to 1,100 KB each. The component samples them pixel by pixel through a canvas dithering shader, but AVIF decodes to RGBA in the browser before the canvas reads, so the dither still works.</p>
<pre><code class="language-javascript">await sharp(inputPath)
  .rotate() // honor EXIF orientation
  .resize({ width: 400, withoutEnlargement: true })
  .avif({ quality: 47, effort: 6, chromaSubsampling: &quot;4:2:0&quot; })
  .toFile(outputPath);
</code></pre>
<p>10.5 MB to 113 KB across the set. 99% reduction. The visual output is indistinguishable from the originals because the dither pass downsamples everything anyway.</p>
<p>Bun blocks postinstall scripts by default per the project&#39;s <code>bunfig.toml</code>. <code>sharp</code> ships its native binary through <code>@img/sharp-*</code> optional dependencies rather than a postinstall, so no <code>bun pm trust</code> was required.</p>
<h3>Convert the project thumbnails and decorations</h3>
<p>Same conversion, run over <code>/public/images/projects/</code> and <code>/public/images/decorations/</code>. 25 thumbnails plus 3 decoration PNGs. 1.16 MB to 91 KB total.</p>
<p>The conversion script lives at <code>scripts/convert-an-to-avif.mjs</code> with <code>convert</code> and <code>reencode</code> modes so reruns are idempotent. The reencode mode is what I used to drop the avatar AVIFs from width 800 to width 400 after a follow up audit said they were still oversized for their displayed dimensions.</p>
<h3>Modernize the browserslist</h3>
<p>Lighthouse&#39;s &quot;Legacy JavaScript&quot; insight flagged an <code>Array.prototype.at</code> polyfill shipped in Next&#39;s client bundle. Added an explicit browserslist field to <code>package.json</code> targeting Chrome 92, Firefox 90, Safari 15.4, Edge 92 and newer. Those are the versions where <code>.at()</code> ships natively, so Next&#39;s SWC compiler drops the polyfill.</p>
<pre><code class="language-json">&quot;browserslist&quot;: [
  &quot;chrome &gt;= 92&quot;,
  &quot;firefox &gt;= 90&quot;,
  &quot;safari &gt;= 15.4&quot;,
  &quot;edge &gt;= 92&quot;,
  &quot;not dead&quot;
]
</code></pre>
<p>Saves about 14 KB on the client bundle. The pre-2022 traffic this excludes is rounding error for a 2026 portfolio site.</p>
<h3>Make the LCP element discoverable</h3>
<p>After the asset fixes, the homepage&#39;s LCP became the Puerto Rico flag sticker in the LogoEngraving bar (smallest decoration but the first visible image content). Lighthouse correctly identified it but flagged it for being lazy loaded with no fetch priority hint.</p>
<p>Added <code>priority</code> to the three LogoEngraving Image components:</p>
<pre><code class="language-tsx">&lt;Image priority src=&quot;/images/decorations/pr-flag.avif&quot; ... /&gt;
</code></pre>
<p><code>priority</code> on Next/Image sets <code>loading=&quot;eager&quot;</code>, <code>fetchpriority=&quot;high&quot;</code>, and emits a <code>&lt;link rel=&quot;preload&quot;&gt;</code> in the HTML head. The LCP element is now in the document&#39;s critical path before the JS parser sees anything else.</p>
<h3>Remove the dead Typekit stylesheet</h3>
<p>The root layout was pulling in <code>https://use.typekit.net/qwf2lae.css</code> as a <code>&lt;link rel=&quot;stylesheet&quot;&gt;</code>. A grep confirmed no font-family declarations anywhere on the site reference any Typekit font. All typography routes through Geist. The stylesheet was render blocking dead weight from an earlier design. Removed.</p>
<h2>What I deliberately did not do</h2>
<p>Per Google&#39;s guide and basic restraint:</p>
<ul>
<li><strong>No new schema types</strong> like FAQPage, HowTo, or extended Article variants. The five already in place (WebSite, Person, BlogPosting, BreadcrumbList, CreativeWork) cover what gets rich result formatting. Adding more is maintenance debt with no measured upside.</li>
<li><strong>No content rewrites</strong> to be &quot;AI friendly.&quot; The blog still reads like a human wrote it because a human does.</li>
<li><strong>No third party AEO services or &quot;AI submission directories.&quot;</strong> Those are 2024 era spam moves that the guide explicitly warns against.</li>
<li><strong>No GA replacement.</strong> Google Analytics is now the heaviest single request on the page at 158 KB, but ripping it out before having a replacement in place would lose two years of historical traffic data. Trade later, not now.</li>
</ul>
<h2>What&#39;s left</h2>
<p>A few items the audit surfaced that are not yet fixed:</p>
<ul>
<li>The <code>render-blocking-insight</code> flags the page&#39;s own CSS chunk. Standard Tailwind output, not worth splitting on a portfolio site.</li>
<li>Some image dimensions still test slightly larger than display at 1x. <code>DitherImage</code> uses a raw <code>&lt;img&gt;</code> for canvas sampling, so it bypasses Next/Image&#39;s automatic <code>srcset</code> generation. A future pass could wrap it.</li>
<li>The next thing to ship is probably swapping Google Analytics for something lighter, then re-auditing. That trade has its own tradeoffs and is worth its own post.</li>
</ul>
<h2>The companion guide</h2>
<p><a href="/guides/auditing-and-optimizing-for-ai-search.md">/guides/auditing-and-optimizing-for-ai-search.md</a> is the step by step playbook. It opens with an agent prompt block so you can paste it into Claude or any other coding agent and have it walk you through the same audit on your own Next.js site. It covers:</p>
<ol>
<li>Search Console verification (URL prefix vs Domain property)</li>
<li>Running the Lighthouse Node CLI and parsing the JSON output</li>
<li>The asset optimization workflow (sharp, ffmpeg, AVIF, MP3)</li>
<li>IndexNow setup with a targeted GitHub Action</li>
<li>browserslist modernization</li>
<li>Common accessibility regressions and how to find them</li>
<li>Making the LCP element a <code>priority</code> image</li>
<li>A verification block that confirms the audit passed</li>
</ol>
<p>The guide is the executable version of this post. If you only want the punch list, start there.</p>
]]></content:encoded>
      <pubDate>Sat, 16 May 2026 00:00:00 GMT</pubDate>
      <author>agnel@agnelnieves.com (Agnel Nieves)</author>
      <dc:creator><![CDATA[Agnel Nieves]]></dc:creator>
      <category>SEO</category>
      <category>AEO</category>
      <category>GEO</category>
      <category>Performance</category>
      <category>Lighthouse</category>
      <category>Accessibility</category>
      <category>AI</category>
      <category>Next.js</category>
    </item>
    <item>
      <title><![CDATA[Benchmarking the Rust JavaScript Toolchain in 2026: Real Numbers from a Real Migration]]></title>
      <link>https://agnelnieves.com/blog/benchmarking-the-rust-javascript-toolchain-in-2026</link>
      <guid isPermaLink="true">https://agnelnieves.com/blog/benchmarking-the-rust-javascript-toolchain-in-2026</guid>
      <description><![CDATA[I migrated this Next.js 16 site from the npm/webpack/ESLint/tsc stack to bun/Turbopack/Biome/oxlint/tsgo and measured every step. Lint is 30 to 50x faster, typecheck is 7x, install is 2.7x. Honest numbers and surprises.]]></description>
      <content:encoded><![CDATA[<h2>TL;DR</h2>
<p>Two weeks ago I <a href="/blog/rust-owns-the-javascript-toolchain-in-2026">argued</a> that by 2026 the JavaScript build pipeline is mostly Rust binaries. Then I migrated this site (Next.js 16, App Router, MDX blog, Radix components, the works) and measured every step before and after. Cold lint went from 3,631 ms to 68 ms, a <strong>53x speedup</strong>. Cold typecheck went from 3,176 ms to 222 ms (<strong>14x</strong>). Production build dropped from 19,948 ms to 10,349 ms (<strong>1.9x</strong>). Cold dependency install fell from 31.9 s to 12.0 s (<strong>2.7x</strong>). The lockfile shrank 38%. The dev tools themselves left the npm graph.</p>
<p>The one counterintuitive finding: my <code>node_modules</code> directory got <em>bigger</em>, from 1.3 GB to 1.6 GB. Native Rust binaries ship pre-compiled per platform, and they cost real disk. The savings live in install time, lockfile size, and the supply chain attack surface, not bytes on disk.</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Old</th>
<th>New</th>
<th>Speedup</th>
</tr>
</thead>
<tbody><tr>
<td>Cold dependency install</td>
<td>31.9 s (npm)</td>
<td>12.0 s (bun)</td>
<td>2.7x</td>
</tr>
<tr>
<td>Lint</td>
<td>3,631 ms (eslint)</td>
<td>68 ms (oxlint)</td>
<td><strong>53x</strong></td>
</tr>
<tr>
<td>Lint (formatter incl.)</td>
<td>3,631 ms (eslint)</td>
<td>124 ms (biome check)</td>
<td><strong>29x</strong></td>
</tr>
<tr>
<td>Typecheck</td>
<td>3,176 ms cold (tsc)</td>
<td>222 ms (tsgo)</td>
<td><strong>14x</strong></td>
</tr>
<tr>
<td>Production build, cold</td>
<td>19.9 s (webpack)</td>
<td>10.3 s (Turbopack)</td>
<td>1.9x</td>
</tr>
<tr>
<td>Dev server time-to-ready</td>
<td>684 ms (webpack)</td>
<td>433 ms (Turbopack)</td>
<td>1.6x</td>
</tr>
<tr>
<td>Lockfile size</td>
<td>669 KB (package-lock.json)</td>
<td>416 KB (bun.lock)</td>
<td>-38%</td>
</tr>
<tr>
<td>Top-level packages</td>
<td>1,362</td>
<td>875</td>
<td>-36%</td>
</tr>
<tr>
<td>node_modules size</td>
<td>1.3 GB</td>
<td>1.6 GB</td>
<td><strong>+23%</strong></td>
</tr>
</tbody></table>
<h2>The setup</h2>
<p>This is a personal site. Real, but small. The numbers below are what one design engineer ships in real life, not a synthetic mega-monorepo. The repo has:</p>
<ul>
<li>Next.js 16 (App Router)</li>
<li>13 published blog posts in MDX</li>
<li>11 components touching Radix UI (accordion, dropdown, slot)</li>
<li>Tailwind for styling, with a shadcn-style HSL CSS variable theme</li>
<li>A small Rust CLI in <code>cli/</code> that serves the <a href="/blog/building-a-terminal-portfolio-you-can-ssh-into">terminal portfolio over SSH</a></li>
<li>A handful of API routes (RSS, OG images, JSON feed, the <a href="/blog/x402-paywall-for-ai-agents">x402 paywall demo</a>)</li>
</ul>
<p>What changed:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Package manager</td>
<td>npm</td>
<td>bun</td>
</tr>
<tr>
<td>Bundler</td>
<td>webpack (<code>next dev/build --webpack</code>)</td>
<td>Turbopack (Next 16 default)</td>
</tr>
<tr>
<td>Linter</td>
<td>ESLint 9 + <code>eslint-config-next</code></td>
<td>Biome 2 + oxlint</td>
</tr>
<tr>
<td>Formatter</td>
<td>none</td>
<td>Biome 2</td>
</tr>
<tr>
<td>Type checker</td>
<td>TypeScript 5.9 (<code>tsc</code>)</td>
<td>TypeScript 7 beta (<code>tsgo</code>, Go-based)</td>
</tr>
<tr>
<td>CSS engine</td>
<td>Tailwind 3.4 + PostCSS + <code>tailwindcss-animate</code></td>
<td>Tailwind 4.3 + Lightning CSS + <code>tw-animate-css</code></td>
</tr>
<tr>
<td>Lockfile</td>
<td><code>package-lock.json</code></td>
<td><code>bun.lock</code> (text format)</td>
</tr>
<tr>
<td>Pre-commit</td>
<td>none</td>
<td><code>.githooks/pre-commit</code> (oxlint + biome + tsgo)</td>
</tr>
<tr>
<td>CI audit</td>
<td>none</td>
<td>OSV-Scanner + <code>bun audit</code> + <code>cargo audit</code> + <code>cargo-deny</code></td>
</tr>
</tbody></table>
<p>Eight commits, four PRs in spirit, one branch landed cleanly into <code>main</code>. The full migration log is published at <a href="/TOOLCHAIN.md">/TOOLCHAIN.md</a> for anyone who wants the move-by-move.</p>
<h2>How I measured</h2>
<p>Same machine, same wall outlet, same coffee. Apple Silicon laptop, plugged in, no other heavy processes running. Each measurement averaged over three runs. The bench script that produced these numbers lives at <code>scripts/bench.sh</code> and is short enough to read in one sitting.</p>
<p>For the old toolchain numbers, I checked out the last pre-migration commit (<code>4f00332</code>), wiped <code>node_modules</code>, ran <code>npm install</code> from scratch, then ran each tool with <code>time</code>. For the new numbers, I checked out the current <code>main</code>, wiped <code>node_modules</code>, ran <code>bun install</code>, then ran the new tools the same way. No tricks, no warm caches between toolchains.</p>
<p>A few honest caveats up front:</p>
<ol>
<li><strong>Wall-clock numbers vary.</strong> Runs jitter ~10% with background load. I averaged three samples and reported the mean for lint and typecheck. Builds and installs are single-shot; nobody runs an install five times for a benchmark.</li>
<li><strong>Time-to-ready is the easiest metric to game.</strong> Webpack reports &quot;ready&quot; before it has compiled most routes (lazy compilation). Turbopack precompiles more aggressively. The wall numbers below are end-to-end from <code>npm run dev</code> invocation to the first &quot;Ready in&quot; line, which is closer to what you actually feel.</li>
<li><strong>macOS file caching helps every run after the first.</strong> I removed <code>.next/</code> between build samples but did not flush OS-level page cache. The numbers are realistic for repeated dev cycles, not for a fresh machine.</li>
</ol>
<h2>Results</h2>
<h3>Cold dependency install</h3>
<pre><code>npm install   31,923 ms   2,205 packages installed
bun install   11,972 ms     875 top-level / 1,762 total packages
</code></pre>
<p><code>bun install</code> is 2.7x faster on this project. Both ran cold (no <code>node_modules</code>, no global package cache primed). The bun lockfile is text format (<code>bun.lock</code>, 416 KB) which diffs cleanly in pull requests. The npm one was 669 KB of binary-ish JSON noise.</p>
<p>The package count drop matters more than the time. The new lockfile has 36% fewer top-level entries, mostly because removing ESLint sheds about 60 transitive dependencies and Tailwind v4 absorbs the PostCSS chain into Lightning CSS. Smaller surface area means fewer maintainers I need to trust before a <code>bun install</code> runs code on my machine.</p>
<p>Bun&#39;s other supply-chain win is its default postinstall policy. Bun blocks postinstall scripts by default and requires packages to be explicitly listed in <code>trustedDependencies</code> in <code>package.json</code> to run them. On this repo, two packages tried to run postinstalls during install: <code>@vercel/speed-insights</code> (an analytics-id sanity check) and <code>@coinbase/x402</code> (a TOS notice). Both are informational. Bun blocked both. Net postinstalls executed during my install: zero.</p>
<h3>Lint</h3>
<pre><code>eslint    cold avg  3,631 ms  (3 samples: 3874, 3518, 3500 ms)
biome     cold avg    124 ms  (3 samples:  125,  122,  125 ms)   29x
oxlint    warm avg     68 ms  (3 samples:   67,   69,   67 ms)   53x
</code></pre>
<p>This is the headline. A linter going from 3.6 seconds to 68 milliseconds changes how you use it. ESLint at 3.6 s is a thing you run before pushing, with anxiety, sometimes deferring to &quot;I&#39;ll fix that later.&quot; Oxlint at 68 ms is a thing you run on every keystroke without thinking.</p>
<p>The Biome number includes formatter check and import-sort. Oxlint is purely the lint pass. Both are valid comparisons depending on what you want from the tool. In CI I run both: oxlint as a fast gate, Biome as the deeper check that also handles formatting.</p>
<p>What was lost from the migration: zero rules that I actually use. <code>eslint-config-next</code> ships about 60 packages and 17 Next-specific rules. Biome 2 already covers the ones we hit on this codebase: <code>noImgElement</code>, <code>noHeadElement</code>, <code>noDocumentImportInPage</code>, <code>noHeadImportInDocument</code>, <code>noNextAsyncClientComponent</code>, <code>useGoogleFontDisplay</code>, <code>useGoogleFontPreconnect</code>, <code>useExhaustiveDependencies</code>, <code>useHookAtTopLevel</code>. The rules unique to ESLint were almost all about the legacy Pages Router (<code>no-html-link-for-pages</code>, <code>no-page-custom-font</code>, <code>no-script-component-in-head</code>, <code>no-document-styled-jsx</code>). I use App Router exclusively, so they never fired anyway.</p>
<p>I dropped ESLint entirely in two lines: <code>bun remove eslint eslint-config-next</code> and one CI step removed. 60 transitive packages went with it.</p>
<h3>Typecheck</h3>
<pre><code>tsc    cold      3,176 ms
tsc    warm avg  1,601 ms  (1629, 1573 ms)
tsgo   warm avg    228 ms  (222, 221, 242 ms)
</code></pre>
<p><code>tsgo</code> is the Go-based port of TypeScript that Microsoft shipped as the <a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0-beta">7.0 beta</a> on April 21. The package is <code>@typescript/native-preview@beta</code>. Microsoft&#39;s own announcement says you can probably start using it day-to-day, and Bloomberg, Canva, and Figma have shipped it on multi-million-line codebases. Personal portfolio is well below that bar.</p>
<p>The cold-vs-warm gap on <code>tsc</code> is real. The first run pays a JIT and incremental-cache cost, then subsequent runs reuse <code>.tsbuildinfo</code>. <code>tsgo</code> is fast cold and warm because it does not need a JIT to begin with. On this codebase the warm comparison is 1.6 s vs 0.23 s, a 7x speedup. Cold-to-warm for <code>tsc</code> versus warm <code>tsgo</code> is 14x.</p>
<p>The migration was a one-line script change. <code>tsc --noEmit</code> became <code>tsgo --noEmit</code> and that was the entire diff. I kept <code>typescript@5.9.3</code> installed alongside because Next.js&#39;s <code>tsconfig.json</code> plugin wants the regular <code>typescript</code> peer for the editor language server, and IntelliSense in VS Code falls back to it for anyone not running the Native Preview extension.</p>
<h3>Production build</h3>
<pre><code>webpack    cold  19,948 ms
turbopack  cold  10,349 ms   1.9x
</code></pre>
<p>Both built the same 45 routes (13 blog posts, 1 deck, work pages, API routes, OG image generators, sitemap, robots, RSS, JSON feed). Turbopack is the default in Next 16. The <code>--webpack</code> flag still exists if you need it, mostly for compatibility with custom webpack loaders that have not been ported.</p>
<p>This is the most modest improvement on the page (1.9x), and it lines up with what Vercel has <a href="https://nextjs.org/blog/next-16">publicly reported</a>: roughly 2 to 5x faster production builds in the wild. Build perf is harder to win than lint perf because a bundler is doing real, irreducible work (parsing, scope analysis, tree-shaking, code-splitting, minification, source maps, asset optimization) that does not shrink as much when you rewrite it in Rust. The bigger wins come from Rust&#39;s better parallelism on multicore machines.</p>
<p>For incremental builds, the gap widens significantly. I did not script that comparison cleanly enough to report numbers, but anecdotally an MDX edit goes from &quot;make a coffee&quot; to &quot;blink and miss it.&quot; The HMR loop is what you actually feel.</p>
<h3>Dev server time-to-ready</h3>
<pre><code>webpack    684 ms (Next reports: 260 ms)
turbopack  433 ms (Next reports: 294 ms)
</code></pre>
<p>This one is closer than I expected and worth being honest about. Webpack often reports &quot;Ready&quot; before it has compiled the routes you have not visited yet (lazy compilation). Turbopack precompiles more eagerly. The wall numbers above are from <code>npm run dev</code> to the first &quot;Ready in&quot; line, which is the user-visible signal.</p>
<p>The real Turbopack win is what happens after that first &quot;Ready.&quot; On a route navigation or a file edit, Turbopack invalidates and recompiles in tens of milliseconds where webpack often took hundreds. The blog post I <a href="/blog/rust-owns-the-javascript-toolchain-in-2026">linked at the top</a> cites Vercel&#39;s number of roughly 87% faster dev startup on real Next 16 apps once you account for typical edit-and-reload cycles, not just first boot.</p>
<h3>Lockfile, dependency tree, and disk</h3>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Old</th>
<th>New</th>
<th>Delta</th>
</tr>
</thead>
<tbody><tr>
<td>Lockfile</td>
<td><code>package-lock.json</code> 669 KB</td>
<td><code>bun.lock</code> 416 KB</td>
<td>-38%</td>
</tr>
<tr>
<td>Top-level packages</td>
<td>1,362</td>
<td>875</td>
<td>-36%</td>
</tr>
<tr>
<td>Total packages (incl. nested)</td>
<td>2,205</td>
<td>1,762</td>
<td>-20%</td>
</tr>
<tr>
<td>node_modules size</td>
<td>1.3 GB</td>
<td>1.6 GB</td>
<td>+23%</td>
</tr>
</tbody></table>
<p>The dependency tree shrank significantly in count, but the size on disk grew. This is the most counterintuitive finding of the migration and worth dwelling on, because it cuts against the easy &quot;Rust toolchain is leaner&quot; narrative.</p>
<p>What&#39;s happening: native Rust and Go binaries ship pre-compiled per platform. <code>@biomejs/biome</code> ships about 50 MB per platform. <code>@typescript/native-preview</code> ships the tsgo Go binary at ~150 MB. <code>@tailwindcss/oxide</code> (Lightning CSS) is another ~40 MB. Bun also installs platform binaries for <code>next/swc</code> even though it&#39;s the same swc; the npm install picks just one and bun grabs all of them by default. Add it up and the new toolchain costs about 300 MB more on disk than the old one, despite needing 36% fewer packages.</p>
<p>Where the cost actually matters: install time (where bun amortizes parallel binary downloads) and supply-chain surface area (where 875 npm packages with maintainers I do not know is half as many as 1,362). The disk bytes are the cheapest part of the equation. SSDs are huge and getting cheaper. Maintainer trust does not get cheaper.</p>
<h2>What this means in practice</h2>
<p>Three things shifted in how I work after the migration.</p>
<p><strong>Pre-commit checks are now free.</strong> I added a <code>.githooks/pre-commit</code> shell script that runs oxlint, Biome, and tsgo on every commit. Total runtime on this codebase: about 750 ms. That is not a hook you grumble through, it is a hook you barely notice. The full sequence used to take 7 to 10 seconds with the old toolchain, which is exactly slow enough to make people commit with <code>--no-verify</code> after the first day.</p>
<p><strong>CI minutes get cheaper.</strong> GitHub Actions bills by the minute. The js job in <code>.github/workflows/ci.yml</code> used to spend roughly 12 to 18 seconds in the lint/typecheck/build sequence; now it spends about 4 to 6 seconds. On a public repo with frequent contributors that scales. On this private repo I switched the workflow to manual trigger only and let the pre-commit hook handle most local verification, which saves the entire workflow run for the cases where it is actually needed.</p>
<p><strong>Supply-chain hardening became practical.</strong> With the dev toolchain leaving the npm graph, the surface area shrank meaningfully. Combined with bun&#39;s default postinstall blocking, OSV-Scanner in CI, and <code>bun audit</code> plus <code>cargo audit</code> running on every push when CI is on, I can see every advisory affecting the project in one screen. The current count: 6 transitive advisories, all upstream-blocked (the web3 stack via <code>@coinbase/x402</code>, <code>@walletconnect</code>, and the <code>russh</code> dep tree on the Rust side). They surface as warnings; none are actionable from my side. That is the right state to be in.</p>
<h2>What did not improve</h2>
<p>For honesty&#39;s sake, here are the parts of the migration that did not pay off, or where the wins are smaller than the marketing suggests.</p>
<p><strong>Disk usage went up, not down.</strong> Discussed above. If you are working on a machine with constrained disk (an old Air, a CI runner with quota), this matters.</p>
<p><strong>Initial dev startup is only 1.6x faster.</strong> The HMR loop and edit-recompile cycle are dramatically better, but if you are someone who runs <code>bun run dev</code> once and leaves it open all day, the boot-time win is small. Turbopack pays its dividends incrementally.</p>
<p><strong>Production build is 1.9x faster, not 5x.</strong> The Vercel marketing materials cite up to 5x for some workloads, but this depends heavily on what your build is doing. A site with lots of MDX (like this one) spends a meaningful chunk of build time in remark and rehype plugins running pure JavaScript, and Rust does not help there. If your bottleneck is webpack&#39;s bundle pass on TypeScript, the speedup is closer to the marketing number. If it is content processing, you save less.</p>
<p><strong>Some advisories will not go away.</strong> Three of the npm advisories (<code>ws</code>, <code>postcss</code>, <code>bn.js</code>) and three of the cargo ones (<code>lru</code>, <code>paste</code>, <code>rsa</code>) are stuck waiting for upstream packages I do not control to bump. The migration improved my visibility into them but did not remove them. Anyone telling you their Rust toolchain migration &quot;fixed all advisories&quot; is lying or has no transitive deps.</p>
<p><strong>The codemods only get you 70% of the way.</strong> <code>bunx @tailwindcss/upgrade</code> crashed on this codebase mid-flight because of an <code>@apply border-border</code> ordering issue in <code>globals.css</code>. The shadcn-style HSL theme indirection had to be done manually with <code>@theme inline</code>. Plugin migrations (tailwindcss-animate to tw-animate-css) are not automated. Plan on doing manual cleanup after every codemod, even when the docs imply it is one command.</p>
<h2>Try it on your own project</h2>
<p>Drop the script below into <code>scripts/bench.sh</code> (or wherever you like) and <code>chmod +x</code> it. It works on any Node-ish project; just adjust the <code>bun run</code> calls to match your scripts. The plain-<code>time</code> fallback means you do not need <code>hyperfine</code> installed, but <code>brew install hyperfine</code> will give you cleaner numbers.</p>
<p>The recipe to capture both old and new on the same machine:</p>
<pre><code class="language-sh"># new toolchain numbers
bash scripts/bench.sh --full

# old toolchain numbers (after checking out the pre-migration commit)
git stash
git checkout &lt;pre-migration-sha&gt;
rm -rf node_modules bun.lock
npm install
bash scripts/bench.sh --full
git checkout main
rm -rf node_modules
bun install
</code></pre>
<p>The output is a markdown-shaped table you can paste straight into a writeup like this one.</p>
<h3>The script</h3>
<pre><code class="language-bash">#!/usr/bin/env bash
# Toolchain benchmark.
# Measures install, lint, typecheck, build, and dev-server-ready timings on
# the checked-out tree. Re-run on an old commit (pre-migration) and on HEAD
# to compare. Output goes to stdout as a markdown table.
#
# Usage:
#   scripts/bench.sh                # Quick run (3 samples each, no install)
#   scripts/bench.sh --full         # Full run (5 samples, includes cold install)

set -e
cd &quot;$(git rev-parse --show-toplevel)&quot;

FULL=0
[[ &quot;$1&quot; == &quot;--full&quot; ]] &amp;&amp; FULL=1

SAMPLES=3
[[ $FULL -eq 1 ]] &amp;&amp; SAMPLES=5

HAS_HYPERFINE=0
command -v hyperfine &gt;/dev/null 2&gt;&amp;1 &amp;&amp; HAS_HYPERFINE=1

H=$&#39;\033[33m&#39;
R=$&#39;\033[0m&#39;

git_short=$(git rev-parse --short HEAD)
git_msg=$(git log -1 --pretty=%s | head -c 60)
echo &quot;Benchmarking commit ${git_short}: ${git_msg}&quot;
echo &quot;Samples: ${SAMPLES}, hyperfine: $([ $HAS_HYPERFINE -eq 1 ] &amp;&amp; echo yes || echo &#39;no (using time)&#39;)&quot;
echo &quot;&quot;

run_bench() {
  local name=&quot;$1&quot;
  local cmd=&quot;$2&quot;
  printf &quot;${H}== %s ==${R}\n&quot; &quot;$name&quot;
  if [[ $HAS_HYPERFINE -eq 1 ]]; then
    hyperfine --warmup 1 --runs $SAMPLES --show-output --shell=bash &quot;$cmd&quot; 2&gt;&amp;1 | tail -8
  else
    local total=0
    for i in $(seq 1 $SAMPLES); do
      local start=$(date +%s%N)
      eval &quot;$cmd&quot; &gt;/dev/null 2&gt;&amp;1 || true
      local end=$(date +%s%N)
      local elapsed=$(( (end - start) / 1000000 ))
      total=$(( total + elapsed ))
      printf &quot;  run %d: %d ms\n&quot; $i $elapsed
    done
    printf &quot;  avg:   %d ms\n&quot; $(( total / SAMPLES ))
  fi
  echo &quot;&quot;
}

clean_next() { rm -rf .next; }

echo &quot;== Lockfile + node_modules ==&quot;
LOCKFILE=$(ls -1 bun.lock package-lock.json pnpm-lock.yaml yarn.lock 2&gt;/dev/null | head -1 || echo &quot;(none)&quot;)
LOCK_SIZE=$(du -h &quot;$LOCKFILE&quot; 2&gt;/dev/null | cut -f1 || echo &quot;?&quot;)
NM_SIZE=$(du -sh node_modules 2&gt;/dev/null | cut -f1 || echo &quot;(no node_modules)&quot;)
NM_COUNT=$(find node_modules -mindepth 1 -maxdepth 2 -type d 2&gt;/dev/null | wc -l | tr -d &#39; &#39; || echo &quot;?&quot;)
echo &quot;lockfile:       $LOCKFILE ($LOCK_SIZE)&quot;
echo &quot;node_modules:   $NM_SIZE&quot;
echo &quot;package count:  $NM_COUNT&quot;
echo &quot;&quot;

if [[ $FULL -eq 1 ]]; then
  echo &quot;${H}== Cold install (rm -rf node_modules) ==${R}&quot;
  rm -rf node_modules
  if command -v bun &gt;/dev/null 2&gt;&amp;1; then
    PKG_CMD=&quot;bun install&quot;
  elif command -v pnpm &gt;/dev/null 2&gt;&amp;1; then
    PKG_CMD=&quot;pnpm install&quot;
  else
    PKG_CMD=&quot;npm install&quot;
  fi
  echo &quot;running: $PKG_CMD&quot;
  /usr/bin/time -p $PKG_CMD 2&gt;&amp;1 | tail -5
  echo &quot;&quot;
fi

run_bench &quot;lint (biome check .)&quot; &quot;bun run lint&quot;
run_bench &quot;lint:fast (oxlint)&quot; &quot;bun run lint:fast&quot;
run_bench &quot;typecheck&quot; &quot;bun run typecheck&quot;

echo &quot;${H}== build (cold, removes .next first) ==${R}&quot;
clean_next
/usr/bin/time -p bun run build &gt; /tmp/bench-build.log 2&gt;&amp;1 || cat /tmp/bench-build.log
grep -E &quot;real |compiled|next build&quot; /tmp/bench-build.log | tail -5
echo &quot;&quot;

echo &quot;${H}== dev server time-to-ready ==${R}&quot;
clean_next
START=$(date +%s%N)
bun run dev &gt; /tmp/bench-dev.log 2&gt;&amp;1 &amp;
DEV_PID=$!
for i in $(seq 1 60); do
  if grep -q &quot;Ready in&quot; /tmp/bench-dev.log 2&gt;/dev/null; then
    END=$(date +%s%N)
    READY_MS=$(( (END - START) / 1000000 ))
    READY_LINE=$(grep &quot;Ready in&quot; /tmp/bench-dev.log | head -1)
    echo &quot;dev ready: ${READY_MS}ms (next reports: ${READY_LINE})&quot;
    break
  fi
  sleep 0.5
done
kill $DEV_PID 2&gt;/dev/null || true
wait 2&gt;/dev/null || true
echo &quot;&quot;
</code></pre>
<p>It is intentionally one file with no dependencies. If you want to extend it (add HMR latency, RSS, memory footprint), the <code>run_bench</code> helper is the only piece you need to reuse.</p>
<h2>What I would change if I were doing this again</h2>
<ol>
<li><strong>Run the codemods first, separately, on a clean branch.</strong> I bundled the codemod with the manual cleanup and ended up with one big commit instead of a clean codemod checkpoint plus a manual cleanup commit. Keep them separate so you can revert just the manual part if you mess up.</li>
<li><strong>Capture before-numbers up front.</strong> I had to check out the pre-migration commit and reinstall to get the old numbers retroactively. Running the bench on the pre-migration commit before starting saves an hour of branch swapping later.</li>
<li><strong>Move ESLint last, not first.</strong> I kept ESLint as a &quot;backstop&quot; through most of the migration, which meant carrying its 60 dependencies and one extra CI step longer than necessary. After verifying Biome&#39;s Next-rule coverage, dropping ESLint was a one-line PR I should have done day one.</li>
<li><strong>Skip the formatter PR until you are ready for the diff.</strong> Enabling Biome&#39;s formatter created a 1,440-line cosmetic diff. Worth doing, but worth doing as its own commit on a quiet day, not on top of a Tailwind migration.</li>
<li><strong>Set up the pre-commit hook on day one.</strong> The faster the local verification loop is, the more aggressively you iterate. I left this until the end and immediately wished I had done it first.</li>
</ol>
<h2>Cited sources</h2>
<ul>
<li><a href="/blog/rust-owns-the-javascript-toolchain-in-2026">&quot;Rust Owns the JavaScript Toolchain in 2026&quot;</a>, the case for the migration</li>
<li><a href="https://leerob.com/rust">Lee Robinson&#39;s &quot;Rust Is Eating JavaScript&quot;</a>, the original 2026 update</li>
<li><a href="https://nextjs.org/blog/next-16">Next.js 16 release notes</a> for Turbopack defaults</li>
<li><a href="https://tailwindcss.com/blog/tailwindcss-v4">Tailwind CSS v4 announcement</a> for the Oxide engine</li>
<li><a href="https://biomejs.dev/blog/biome-v2/">Biome v2 release post</a> for type-aware lint claims</li>
<li><a href="https://oxc.rs/blog/2025-06-10-oxlint-stable">Oxlint 1.0 stable release</a> for the 50 to 100x ESLint claim</li>
<li><a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0-beta">TypeScript 7.0 beta announcement</a> for tsgo</li>
</ul>
<hr>
<p><em>Built by <a href="/about">Agnel Nieves</a>, a design engineer with 15+ years across product, design systems, and crypto. The full migration log lives at <a href="/TOOLCHAIN.md">/TOOLCHAIN.md</a>. More writing on <a href="/blog">the blog</a>.</em></p>
]]></content:encoded>
      <pubDate>Thu, 14 May 2026 00:00:00 GMT</pubDate>
      <author>agnel@agnelnieves.com (Agnel Nieves)</author>
      <dc:creator><![CDATA[Agnel Nieves]]></dc:creator>
      <category>Rust</category>
      <category>JavaScript</category>
      <category>Performance</category>
      <category>Benchmark</category>
      <category>Next.js</category>
      <category>Tooling</category>
    </item>
  </channel>
</rss>