<?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:media="http://search.yahoo.com/mrss/">
  <channel>
    <title><![CDATA[KahWee - Web Development, AI Tools & Tech Trends]]></title>
    <link>https://kahwee.com/</link>
    <description><![CDATA[Expert takes on AI tools like Claude and Sora, modern web development with React and Vite, and tech trends. By KahWee.]]></description>
    <language>en-US</language>
    <pubDate>Mon, 02 Feb 2026 17:00:00 GMT</pubDate>
    <lastBuildDate>Mon, 02 Feb 2026 17:00:00 GMT</lastBuildDate>
    <atom:link href="https://kahwee.com/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title><![CDATA[Adobe Animate Moves to Maintenance Mode: Why the Flash Bridge Couldn't Hold]]></title>
      <link>https://kahwee.com/2026/why-adobe-animate-flash-bridge-failed/</link>
      <guid isPermaLink="true">https://kahwee.com/2026/why-adobe-animate-flash-bridge-failed/</guid>
      <pubDate>Mon, 02 Feb 2026 17:00:00 GMT</pubDate>
      <description><![CDATA[Adobe announced Animate would be discontinued, then reversed course after community backlash. Now it's in maintenance mode—but the market forces that killed it aren't Adobe's fault. Here's what happened to the animator's bridge to HTML5.]]></description>
      <category>animation</category>
      <category>flash</category>
      <category>web-history</category>
      <category>adobe</category>
      <category>tools</category>
      <content:encoded><![CDATA[<p>Adobe <a href="https://community.adobe.com/announcements-539/adobe-animate-end-of-life-and-support-timeline-1548220" target="_blank" rel="noopener noreferrer nofollow">emailed Animate customers</a> on February 2 saying the product would be discontinued March 1, 2026. Two days later, <a href="https://techcrunch.com/2026/02/02/adobe-animate-is-shutting-down-as-company-focuses-on-ai/" target="_blank" rel="noopener noreferrer nofollow">after community backlash</a>, Adobe reversed course: <a href="https://helpx.adobe.com/uk/animate/kb/end-of-life.html" target="_blank" rel="noopener noreferrer nofollow">Animate is now in "maintenance mode"</a> indefinitely. Security patches continue. No new features.</p>
<p>Adobe is not the villain here because the market shifted and Animate could not keep up.</p>
<h2>The Bridge That Never Connected</h2>
<p>Animate CC let Flash animators keep their keyframe-and-timeline mental model while targeting JavaScript. However, it never shook off its Flash DNA.</p>
<p>When I <a href="/2017/introduction-to-animate-cc-components/">worked with Animate components in 2017</a>, you could build interactive pieces and games. But Animate exported to CreateJS — a Canvas runtime abstraction that felt like a foreign layer next to modern web stacks. CreateJS was slower than hand-coded Canvas. The whole pipeline felt out of step with JavaScript development. Unity, Godot, even Phaser owned their pipelines end-to-end. Animate's HTML5 output never felt native to the web.</p>
<p>The user base hollowed out. The "semi-technical designer" — people who could both animate and script — split into two camps. Motion designers storyboard concepts. Engineers implement them in Canvas or WebGL. Animate ended up in no-man's land: not opinionated enough as a game engine, not modern enough as a web tool.</p>
<h2>The Workflow That Won</h2>
<p>After Effects → Lottie gave designers a cleaner loop. Animate in a familiar tool, export JSON, hand it to engineers who render it natively on web or mobile. No custom JavaScript runtime. No FLA files.</p>
<p>Spine and Spriter did the same for game animation: export to runtime libraries that integrate cleanly with any engine. Animate's HTML5 Canvas output worked, but felt bolted onto a tool designed for a plugin that no longer exists.</p>
<p>The market wanted separation of concerns, where designers own animation and engineers own runtime. Animate tried to be both.</p>
<p>If you still use Animate, maintenance mode means security patches without ecosystem modernization. The risk shifts from "Adobe kills it next March" to "OS and ecosystem changes eventually outpace a frozen app." It is not advisable to build new work on museum-mode software.</p>
<p>The timeline and keyframe paradigm that made Flash powerful lives on in After Effects, game engines, and web animation libraries. It just does not need Animate anymore.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Getting a DBA in San Francisco: The Real Process and Costs]]></title>
      <link>https://kahwee.com/2026/getting-a-dba-in-san-francisco/</link>
      <guid isPermaLink="true">https://kahwee.com/2026/getting-a-dba-in-san-francisco/</guid>
      <pubDate>Wed, 21 Jan 2026 17:00:00 GMT</pubDate>
      <description><![CDATA[A first-hand account of registering a fictitious business name (DBA) in San Francisco as a sole proprietor. The process, the costs, and what the official instructions don't tell you.]]></description>
      <category>san-francisco</category>
      <category>business</category>
      <category>personal</category>
      <content:encoded><![CDATA[<p>I just registered a DBA (Doing Business As) in San Francisco. The official instructions were confusing, so here's what actually happened.</p>
<blockquote>
<p><strong>TL;DR:</strong> Total cost <strong>$236</strong>. Two stops at City Hall, then email a newspaper. Done in one day (plus 4 weeks of publication).</p>
</blockquote>
<h2>What to Bring</h2>
<ul>
<li> Cash (Tax Collector only accepts cash in person)</li>
<li> Valid photo ID</li>
<li> Pre-filled <a href="https://www.sf.gov/file-your-fbn-statement-form" target="_blank" rel="noopener noreferrer nofollow">FBN Statement form</a></li>
</ul>
<h2>Step 1: Tax Collector (Room 140)</h2>
<p>Go here first. You can't file your FBN without registering your business. City Hall is in the Civic Center area—if you're coming from the <a href="https://yorksf.com/2026/mission-district-guide/" target="_blank" rel="noopener noreferrer nofollow">Mission District</a>, take BART to Civic Center station.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p><strong>Cash only</strong> in person. Credit card is online only - then you'd have to wait for processing.</p>
</div>
<p>Obligation type: "Business Registration," payable to the Treasurer and Tax Collector.</p>
<table>
<thead>
<tr>
<th>Tax Year</th>
<th>Amount</th>
</tr>
</thead>
<tbody><tr>
<td>2026</td>
<td>$45</td>
</tr>
<tr>
<td>2027</td>
<td>$59</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>$104</strong></td>
</tr>
</tbody></table>
<p>Not sure why the amounts differ - maybe prorating.</p>
<p><a href="https://sftreasurer.org/business/register-business" target="_blank" rel="noopener noreferrer nofollow">Official fees</a> for sole proprietors:</p>
<table>
<thead>
<tr>
<th>Gross Receipts</th>
<th>Annual Fee</th>
</tr>
</thead>
<tbody><tr>
<td>$0 - $100k</td>
<td>$45 ($41 + $4 state fee)</td>
</tr>
<tr>
<td>$100k - $250k</td>
<td>$75 ($71 + $4 state fee)</td>
</tr>
</tbody></table>
<p>After paying, you immediately get:</p>
<ul>
<li><strong>Business Account Number (BAN)</strong> - you'll need this for your FBN</li>
<li><strong>Temporary Verification of Registration (TVR)</strong> - required for your FBN filing, serves as proof until official certificate arrives</li>
</ul>
<h2>Step 2: County Clerk (Room 168)</h2>
<p>Walk over to Room 168. This is where you file your Fictitious Business Name (FBN).</p>
<table>
<thead>
<tr>
<th>Item</th>
<th>Details</th>
</tr>
</thead>
<tbody><tr>
<td>Fee</td>
<td><strong>$67</strong> ($55 first name + $13 each additional)</td>
</tr>
<tr>
<td>Payment</td>
<td>Credit card accepted</td>
</tr>
<tr>
<td>Required</td>
<td>Valid photo ID</td>
</tr>
</tbody></table>
<p>Pre-fill the <a href="https://www.sf.gov/file-your-fbn-statement-form" target="_blank" rel="noopener noreferrer nofollow">FBN Statement form</a> before going. The SF.gov instructions weren't clear to me, but the clerks were helpful.</p>
<p>After filing, you get a stamped copy of your FBN. <strong>This is your DBA.</strong></p>
<h2>Step 3: Publish in a Newspaper</h2>
<p>California requires you to publish your FBN once a week for four consecutive weeks.</p>
<div class="markdown-alert markdown-alert-caution">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z"></path></svg>Caution</p>
<p>You have <strong>45 days</strong> from filing to start publication. Miss it and you'll refile + pay $67 again.</p>
</div>
<h3>Newspaper Options</h3>
<p>San Francisco has <a href="https://www.sf.gov/information/san-francisco-court-approved-newspapers" target="_blank" rel="noopener noreferrer nofollow">4 court-approved newspapers</a>. I emailed all four for quotes:</p>
<table>
<thead>
<tr>
<th>Newspaper</th>
<th>Cost</th>
<th>Files Proof?</th>
<th>Contact</th>
</tr>
</thead>
<tbody><tr>
<td>Small Business Exchange</td>
<td>$40</td>
<td>No (+$10 DIY)</td>
<td><a href="mailto:nvo@sbeinc.com">nvo@sbeinc.com</a></td>
</tr>
<tr>
<td><strong>Bay Area Reporter</strong></td>
<td><strong>$65</strong></td>
<td><strong>Yes</strong></td>
<td><a href="mailto:barlegals@gmail.com">barlegals@gmail.com</a></td>
</tr>
<tr>
<td>Daily Journal (DBAStore)</td>
<td>$67</td>
<td>Yes</td>
<td><a href="mailto:sfdj_legal@dailyjournal.com">sfdj_legal@dailyjournal.com</a></td>
</tr>
<tr>
<td>San Francisco Examiner</td>
<td>$88</td>
<td>Yes</td>
<td><a href="mailto:sfexaminer@cnsblegal.com">sfexaminer@cnsblegal.com</a></td>
</tr>
</tbody></table>
<p>Small Business Exchange is $15 cheaper, but you file the proof yourself — either mail it (1-2 week wait) or another trip to City Hall. If your time is worth more than $15/hour, pay for convenience.</p>
<p>I went with <strong>Bay Area Reporter</strong>. They replied within hours. Accepts check, credit card, or PayPal. Deadline: 10am Tuesday for Thursday publication.</p>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>Have a photo or scan of your stamped FBN statement ready to email. The newspaper will ask for it before they can start publication.</p>
</div>
<h2>Step 4: Proof of Publication</h2>
<p>After your fourth publication, you have <strong>45 days</strong> to file the Proof of Publication. Fee is $10.</p>
<p><strong>If your newspaper files for you</strong> (Bay Area Reporter, DBAStore, SF Examiner):</p>
<blockquote>
<p>You're done. They file it and email you a copy.</p>
</blockquote>
<p><strong>If you file yourself</strong> (Small Business Exchange):</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Process</th>
<th>Time</th>
</tr>
</thead>
<tbody><tr>
<td>By mail</td>
<td>$10 check to "SF County Clerk" + self-addressed envelope</td>
<td>1-2 weeks</td>
</tr>
<tr>
<td>In person</td>
<td>Room 168, pay $10</td>
<td>Same day</td>
</tr>
</tbody></table>
<h2>Total Costs</h2>
<table>
<thead>
<tr>
<th>Item</th>
<th>Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Tax Collector - Business Registration (2 years)</td>
<td>$104</td>
</tr>
<tr>
<td>County Clerk - FBN filing</td>
<td>$67</td>
</tr>
<tr>
<td>Bay Area Reporter - publication + proof filing</td>
<td>$65</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>$236</strong></td>
</tr>
</tbody></table>
<h2>Timeline</h2>
<pre><code class="hljs">Day <span class="hljs-number">1</span>        → City Hall (Tax Collector + County Clerk)
Days <span class="hljs-number">2</span><span class="hljs-number">-45</span>    → Email newspaper<span class="hljs-punctuation">,</span> submit FBN for publication
Weeks <span class="hljs-number">2</span><span class="hljs-number">-5</span>    → Publication runs (<span class="hljs-number">4</span> consecutive weeks)
Week <span class="hljs-number">6</span>+      → Proof of publication filed (within <span class="hljs-number">45</span> days of <span class="hljs-number">4</span>th publication)
</code></pre><p>Your FBN expires in <strong>5 years</strong>.</p>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>Mark the 5-year renewal date in your calendar. San Francisco does not send renewal reminders, and an expired FBN means you lose the business name.</p>
</div>
<h2>The Sequence</h2>
<ol>
<li><strong>Tax Collector first</strong> → get your BAN</li>
<li><strong>County Clerk second</strong> → file your FBN</li>
<li><strong>Newspaper third</strong> → publish for 4 weeks</li>
<li><strong>Proof of publication last</strong> → or let the newspaper handle it</li>
</ol>
<hr />
<p><strong>Sources:</strong></p>
<ul>
<li><a href="https://www.sf.gov/step-by-step--file-fictitious-business-name-fbn" target="_blank" rel="noopener noreferrer nofollow">SF.gov: File a Fictitious Business Name</a></li>
<li><a href="https://sftreasurer.org/business/register-business" target="_blank" rel="noopener noreferrer nofollow">SF Treasurer: Register a Business</a></li>
<li><a href="https://countyclerk.sfgov.org/" target="_blank" rel="noopener noreferrer nofollow">SF County Clerk</a></li>
<li><a href="https://www.sf.gov/information/san-francisco-court-approved-newspapers" target="_blank" rel="noopener noreferrer nofollow">Court-Approved Newspapers</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Claude Code's Plugin Marketplace Changes How You Build Frontend]]></title>
      <link>https://kahwee.com/2026/claude-code-plugin-marketplace/</link>
      <guid isPermaLink="true">https://kahwee.com/2026/claude-code-plugin-marketplace/</guid>
      <pubDate>Tue, 20 Jan 2026 19:00:00 GMT</pubDate>
      <description><![CDATA[Claude Code now has a plugin marketplace. The frontend-design skill auto-activates when you're building UI and pushes Claude toward distinctive, production-grade interfaces instead of generic AI slop.]]></description>
      <category>claude</category>
      <category>ai-tool</category>
      <category>developer-tool</category>
      <category>web-development</category>
      <content:encoded><![CDATA[<p>Claude Code added plugin support in version 2.1.0 (<a href="https://claude.com/blog/claude-code-plugins" target="_blank" rel="noopener noreferrer nofollow">public beta October 9, 2025</a>). I missed it until now.</p>
<p>The one that matters: a <code>frontend-design</code> skill that auto-activates during UI work and changes how Claude writes your frontend code.</p>
<h2>How it works</h2>
<p>Run <code>/plugin</code> to browse, install, and manage plugins from marketplaces you've added.</p>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>/plugin</code></td>
<td>Opens the plugin management UI</td>
</tr>
<tr>
<td><code>/plugin marketplace add owner/repo</code></td>
<td>Adds a marketplace (GitHub shorthand works)</td>
</tr>
<tr>
<td><code>/plugin install name@marketplace</code></td>
<td>Installs a specific plugin</td>
</tr>
<tr>
<td><code>/plugin marketplace list</code></td>
<td>Shows your added marketplaces</td>
</tr>
</tbody></table>
<p>Anthropic maintains two official marketplaces:</p>
<ul>
<li><strong><code>anthropics/claude-code</code></strong> — bundled plugins for Agent SDK development, PR reviews, commit workflows</li>
<li><strong><code>claude-plugins-official</code></strong> — curated directory including the <code>frontend-design</code> skill</li>
</ul>
<h2>The frontend-design skill</h2>
<p>From the <a href="https://github.com/anthropics/claude-plugins-official/tree/main/plugins/frontend-design" target="_blank" rel="noopener noreferrer nofollow">plugin README</a>:</p>
<blockquote>
<p>Create distinctive, production-grade frontend interfaces with high design quality. Generates creative, polished code that avoids generic AI aesthetics.</p>
</blockquote>
<p>What makes it different:</p>
<ul>
<li><strong>Auto-invokes contextually</strong> — Claude detects frontend work and the skill kicks in. No slash command needed.</li>
<li><strong>Opinionated design choices</strong> — bolder typography, asymmetric layouts, motion, real spacing instead of safe defaults.</li>
<li><strong>~400 tokens</strong> — a markdown document that rewires Claude's aesthetic sense. Not heavy infrastructure.</li>
</ul>
<h3>Before vs after</h3>
<p>Without the skill, Claude tends to generate:</p>
<ul>
<li>Safe, centered layouts</li>
<li>Default Tailwind spacing</li>
<li>Generic card components</li>
<li>"Clean" but forgettable UI</li>
</ul>
<p>With <code>frontend-design</code> active:</p>
<ul>
<li>Editorial typography choices</li>
<li>Intentional whitespace and asymmetry</li>
<li>Motion and micro-interactions</li>
<li>Distinctive visual personality</li>
</ul>
<h2>Installing it</h2>
<pre><code class="hljs language-bash"># Add the official marketplace (if not already added)
/plugin marketplace add claude-plugins-official

# Install the frontend-design skill
/plugin install frontend-design@claude-plugins-official
</code></pre><p>Or just run <code>/plugin</code>, go to <strong>Discover</strong>, and find <code>frontend-design</code>.</p>
<p>The "Install for you (user scope)" option means it's installed to your profile only—other users on the same machine need to install it separately.</p>
<h2>Why this matters</h2>
<table>
<thead>
<tr>
<th>Problem</th>
<th>How plugins help</th>
</tr>
</thead>
<tbody><tr>
<td>AI-generated UI looks generic</td>
<td><code>frontend-design</code> skill pushes distinctive choices</td>
</tr>
<tr>
<td>Manual setup for each project</td>
<td>Plugins persist across sessions</td>
</tr>
<tr>
<td>Inconsistent code review feedback</td>
<td><code>pr-review</code> plugin standardizes the process</td>
</tr>
<tr>
<td>Forgetting commit conventions</td>
<td><code>commit-commands</code> plugin automates it</td>
</tr>
</tbody></table>
<p>Plugins let Claude Code specialize for your workflow. No re-explaining context every session.</p>
<h2>Other plugins worth installing</h2>
<p>The <a href="https://github.com/anthropics/claude-plugins-official" target="_blank" rel="noopener noreferrer nofollow">claude-plugins-official marketplace</a> has 46+ plugins. The ones I'd install first:</p>
<h3>Development workflows</h3>
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Type</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>feature-dev</code></td>
<td>Command</td>
<td>7-phase feature development workflow with <code>code-explorer</code>, <code>code-architect</code>, and <code>code-reviewer</code> agents</td>
</tr>
<tr>
<td><code>commit-commands</code></td>
<td>Command</td>
<td><code>/commit</code>, <code>/commit-push-pr</code>, <code>/clean_gone</code> for git workflow automation</td>
</tr>
<tr>
<td><code>pr-review-toolkit</code></td>
<td>Command</td>
<td>6 parallel agents for comments, tests, error handling, types, code quality, and simplification</td>
</tr>
<tr>
<td><code>code-review</code></td>
<td>Command</td>
<td>Automated PR review with confidence-based scoring to filter false positives</td>
</tr>
<tr>
<td><code>code-simplifier</code></td>
<td>Agent</td>
<td>Refactors code for clarity and maintainability</td>
</tr>
</tbody></table>
<h3>Language servers (LSP)</h3>
<p>These give Claude better code intelligence for specific languages:</p>
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Language</th>
</tr>
</thead>
<tbody><tr>
<td><code>typescript-lsp</code></td>
<td>TypeScript/JavaScript</td>
</tr>
<tr>
<td><code>pyright-lsp</code></td>
<td>Python</td>
</tr>
<tr>
<td><code>gopls-lsp</code></td>
<td>Go</td>
</tr>
<tr>
<td><code>rust-analyzer-lsp</code></td>
<td>Rust</td>
</tr>
<tr>
<td><code>clangd-lsp</code></td>
<td>C/C++</td>
</tr>
<tr>
<td><code>jdtls-lsp</code></td>
<td>Java</td>
</tr>
<tr>
<td><code>swift-lsp</code></td>
<td>Swift</td>
</tr>
<tr>
<td><code>kotlin-lsp</code></td>
<td>Kotlin</td>
</tr>
<tr>
<td><code>csharp-lsp</code></td>
<td>C#</td>
</tr>
</tbody></table>
<h3>Security and quality</h3>
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Type</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>security-guidance</code></td>
<td>Hook</td>
<td>Monitors 9 security patterns (command injection, XSS, eval, pickle, os.system) and warns before edits</td>
</tr>
<tr>
<td><code>hookify</code></td>
<td>Command</td>
<td>Create custom hooks to prevent unwanted behaviors by analyzing conversation patterns</td>
</tr>
<tr>
<td><code>superpowers</code></td>
<td>Skill</td>
<td>Teaches Claude systematic debugging with TDD</td>
</tr>
</tbody></table>
<h3>Integrations (MCP servers)</h3>
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Integrates with</th>
</tr>
</thead>
<tbody><tr>
<td><code>github</code></td>
<td>GitHub repos, issues, PRs</td>
</tr>
<tr>
<td><code>gitlab</code></td>
<td>GitLab DevOps</td>
</tr>
<tr>
<td><code>linear</code></td>
<td>Linear issue tracking</td>
</tr>
<tr>
<td><code>slack</code></td>
<td>Slack workspaces</td>
</tr>
<tr>
<td><code>figma</code></td>
<td>Figma designs</td>
</tr>
<tr>
<td><code>vercel</code></td>
<td>Vercel deployments</td>
</tr>
<tr>
<td><code>sentry</code></td>
<td>Sentry error monitoring</td>
</tr>
<tr>
<td><code>supabase</code></td>
<td>Supabase database</td>
</tr>
<tr>
<td><code>firebase</code></td>
<td>Firebase backend</td>
</tr>
<tr>
<td><code>playwright</code></td>
<td>Browser automation and E2E testing</td>
</tr>
</tbody></table>
<h3>Learning and output styles</h3>
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Type</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>explanatory-output-style</code></td>
<td>Hook</td>
<td>Adds educational insights about implementation choices</td>
</tr>
<tr>
<td><code>learning-output-style</code></td>
<td>Hook</td>
<td>Requests meaningful code contributions at decision points</td>
</tr>
</tbody></table>
<h3>Plugin development</h3>
<table>
<thead>
<tr>
<th>Plugin</th>
<th>Type</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>plugin-dev</code></td>
<td>Command</td>
<td>8-phase guided workflow for building your own plugins</td>
</tr>
<tr>
<td><code>agent-sdk-dev</code></td>
<td>Command</td>
<td><code>/new-sdk-app</code> for Agent SDK projects with validation agents</td>
</tr>
</tbody></table>
<h2>Security</h2>
<div class="markdown-alert markdown-alert-caution">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z"></path></svg>Caution</p>
<p>Plugins can include MCP servers, file access, or arbitrary code. Stick to official marketplaces and review READMEs before installing third-party plugins.</p>
</div>
<p>Claude Code warns you at install time. The safe practice:</p>
<ul>
<li>Stick to official marketplaces (<code>claude-plugins-official</code>, <code>anthropics/claude-code</code>)</li>
<li>Skim the plugin's README before installing third-party plugins</li>
<li>Use user-scope installs rather than machine-wide when testing</li>
</ul>
<h2>Getting started</h2>
<pre><code class="hljs language-bash"># <span class="hljs-number">1.</span> Add Anthropic's official marketplace
/plugin marketplace add claude-plugins-official

# <span class="hljs-number">2.</span> Install the frontend skill
/plugin install frontend-design@claude-plugins-official

# <span class="hljs-number">3.</span> Start building UI normally
# The skill auto-activates when Claude detects frontend work
</code></pre><p>Give it vibes and constraints, not "make it pretty":</p>
<blockquote>
<p>"Build a landing page for an AI security startup in Next.js + Tailwind. Go for a bold editorial aesthetic with lots of whitespace."</p>
</blockquote>
<h2>I haven't tried this yet</h2>
<p>I discovered this feature and haven't used it on a real project. I'll update this post after testing it. Questions I want answered:</p>
<ul>
<li>Does it handle design systems and component libraries well?</li>
<li>Do the "distinctive" choices ship, or do they need heavy editing?</li>
<li>How does it interact with existing Tailwind/CSS conventions in a codebase?</li>
</ul>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2025/claude-code-strengths-and-weaknesses-mar-2025/">Claude Code Strengths and Weaknesses</a> - My earlier assessment of Claude Code's capabilities</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Why jquery.slim.js?url Behaves Differently in Vite]]></title>
      <link>https://kahwee.com/2026/vite-url-import-jquery/</link>
      <guid isPermaLink="true">https://kahwee.com/2026/vite-url-import-jquery/</guid>
      <pubDate>Tue, 20 Jan 2026 17:00:00 GMT</pubDate>
      <description><![CDATA[In Vite, import 'jquery.slim.js' and import 'jquery.slim.js?url' do completely different things. One executes jQuery, the other gives you a URL string. Here's what ?url actually does and when to use it.]]></description>
      <category>vite</category>
      <category>web-development</category>
      <category>build-tool</category>
      <content:encoded><![CDATA[<p><code>jquery/dist/jquery.slim.js</code> and <code>jquery/dist/jquery.slim.js?url</code> do completely different things in Vite. The <code>?url</code> suffix switches Vite into "asset mode" — you get a string URL, not an executable module.</p>
<h2>The core difference</h2>
<pre><code class="hljs language-js"><span class="hljs-comment">// ✅ Normal import: executes the module</span>
<span class="hljs-keyword">import</span> $ <span class="hljs-keyword">from</span> <span class="hljs-string">'jquery/dist/jquery.slim.js'</span>;
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-keyword">typeof</span> $);  <span class="hljs-comment">// "function" - it's the jQuery API</span>

<span class="hljs-comment">// ❌ URL import: asset URL only</span>
<span class="hljs-keyword">import</span> jquerySlimUrl <span class="hljs-keyword">from</span> <span class="hljs-string">'jquery/dist/jquery.slim.js?url'</span>;
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-keyword">typeof</span> jquerySlimUrl);  <span class="hljs-comment">// "string" - just a path</span>
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(jquerySlimUrl);  <span class="hljs-comment">// "/assets/jquery.slim-a1b2c3d4.js"</span>
</code></pre><table>
<thead>
<tr>
<th>Import Type</th>
<th>What You Get</th>
<th>Code Executed?</th>
<th>Use Case</th>
</tr>
</thead>
<tbody><tr>
<td><code>import $</code></td>
<td>Module exports</td>
<td>✅ Yes</td>
<td>Normal usage</td>
</tr>
<tr>
<td><code>import url from '...?url'</code></td>
<td><code>string</code></td>
<td>❌ No</td>
<td>Workers, iframes</td>
</tr>
</tbody></table>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p><code>?url</code> is not a filter or modifier—it completely changes what Vite does with the file.</p>
</div>
<p><strong>Without <code>?url</code>:</strong></p>
<ul>
<li>Vite runs the file through the JS pipeline</li>
<li>Dependencies are resolved and bundled</li>
<li>You get the module's exports</li>
</ul>
<p><strong>With <code>?url</code>:</strong></p>
<ul>
<li>Vite copies the file to your build output as-is</li>
<li>No bundling, no dependency resolution</li>
<li>You get a string path to that file</li>
</ul>
<hr />
<h2>Why ?url "breaks" your code</h2>
<p>If your code looks like this:</p>
<pre><code class="hljs language-js"><span class="hljs-comment">// 🚫 WRONG: This gives you a string, not jQuery</span>
<span class="hljs-keyword">import</span> $ <span class="hljs-keyword">from</span> <span class="hljs-string">'jquery/dist/jquery.slim.js?url'</span>;

$(<span class="hljs-string">'#app'</span>).<span class="hljs-title function_">hide</span>();
<span class="hljs-comment">// TypeError: $ is not a function</span>
<span class="hljs-comment">// Because $ === "/assets/jquery.slim-a1b2c3d4.js"</span>
</code></pre><p>The fix is simple—remove <code>?url</code>:</p>
<pre><code class="hljs language-js"><span class="hljs-comment">// ✅ CORRECT: This gives you the jQuery function</span>
<span class="hljs-keyword">import</span> $ <span class="hljs-keyword">from</span> <span class="hljs-string">'jquery/dist/jquery.slim.js'</span>;

$(<span class="hljs-string">'#app'</span>).<span class="hljs-title function_">hide</span>();  <span class="hljs-comment">// Works</span>
</code></pre><p>If you see <code>TypeError: x is not a function</code> after an import, check if you accidentally added <code>?url</code> to the import path.</p>
<hr />
<h2>What ?url is actually for</h2>
<p><code>?url</code> exists for cases where you need a file's URL, not its code.</p>
<h3>1. CSS Paint Worklets</h3>
<pre><code class="hljs language-js"><span class="hljs-keyword">import</span> workletUrl <span class="hljs-keyword">from</span> <span class="hljs-string">'./checkered-pattern.js?url'</span>;

<span class="hljs-comment">// Worklet API requires a URL, not code</span>
<span class="hljs-variable constant_">CSS</span>.<span class="hljs-property">paintWorklet</span>.<span class="hljs-title function_">addModule</span>(workletUrl);
</code></pre><h3>2. Audio Worklets</h3>
<pre><code class="hljs language-js"><span class="hljs-keyword">import</span> processorUrl <span class="hljs-keyword">from</span> <span class="hljs-string">'./audio-processor.js?url'</span>;

<span class="hljs-keyword">await</span> audioContext.<span class="hljs-property">audioWorklet</span>.<span class="hljs-title function_">addModule</span>(processorUrl);
<span class="hljs-keyword">const</span> node = <span class="hljs-keyword">new</span> <span class="hljs-title class_">AudioWorkletNode</span>(audioContext, <span class="hljs-string">'my-processor'</span>);
</code></pre><h3>3. Injecting scripts into iframes</h3>
<pre><code class="hljs language-js"><span class="hljs-keyword">import</span> jqueryUrl <span class="hljs-keyword">from</span> <span class="hljs-string">'jquery/dist/jquery.slim.js?url'</span>;

<span class="hljs-keyword">const</span> script = <span class="hljs-variable language_">document</span>.<span class="hljs-title function_">createElement</span>(<span class="hljs-string">'script'</span>);
script.<span class="hljs-property">src</span> = jqueryUrl;  <span class="hljs-comment">// Need URL here, not code</span>
iframe.<span class="hljs-property">contentDocument</span>.<span class="hljs-property">head</span>.<span class="hljs-title function_">appendChild</span>(script);
</code></pre><h3>4. Web Workers (alternative syntax)</h3>
<pre><code class="hljs language-js"><span class="hljs-comment">// Option A: Vite's ?worker suffix (recommended)</span>
<span class="hljs-keyword">import</span> <span class="hljs-title class_">MyWorker</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./worker.js?worker'</span>;
<span class="hljs-keyword">const</span> worker = <span class="hljs-keyword">new</span> <span class="hljs-title class_">MyWorker</span>();

<span class="hljs-comment">// Option B: Manual with ?url</span>
<span class="hljs-keyword">import</span> workerUrl <span class="hljs-keyword">from</span> <span class="hljs-string">'./worker.js?url'</span>;
<span class="hljs-keyword">const</span> worker = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Worker</span>(workerUrl, { <span class="hljs-attr">type</span>: <span class="hljs-string">'module'</span> });
</code></pre><hr />
<h2>All Vite import suffixes</h2>
<p>Vite recognizes these query suffixes:</p>
<table>
<thead>
<tr>
<th>Suffix</th>
<th>Returns</th>
<th>Bundled?</th>
<th>Example Use</th>
</tr>
</thead>
<tbody><tr>
<td><em>(none)</em></td>
<td>Module exports</td>
<td>✅ Yes</td>
<td>Normal imports</td>
</tr>
<tr>
<td><code>?url</code></td>
<td><code>string</code> (URL)</td>
<td>❌ No</td>
<td>Worklets, iframes</td>
</tr>
<tr>
<td><code>?raw</code></td>
<td><code>string</code> (contents)</td>
<td>❌ No</td>
<td>GLSL shaders, SQL</td>
</tr>
<tr>
<td><code>?worker</code></td>
<td><code>Worker</code> class</td>
<td>✅ Yes</td>
<td>Web Workers</td>
</tr>
<tr>
<td><code>?worker&amp;url</code></td>
<td><code>string</code> (Worker URL)</td>
<td>✅ Yes</td>
<td>Worker URL only</td>
</tr>
<tr>
<td><code>?inline</code></td>
<td><code>string</code> (data URI)</td>
<td>❌ No</td>
<td>Inline assets</td>
</tr>
</tbody></table>
<pre><code class="hljs language-js"><span class="hljs-comment">// ?raw - get file contents as string</span>
<span class="hljs-keyword">import</span> vertexShader <span class="hljs-keyword">from</span> <span class="hljs-string">'./shader.vert?raw'</span>;
gl.<span class="hljs-title function_">shaderSource</span>(shader, vertexShader);

<span class="hljs-comment">// ?worker - get a Worker constructor</span>
<span class="hljs-keyword">import</span> <span class="hljs-title class_">AnalyticsWorker</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./analytics.js?worker'</span>;
<span class="hljs-keyword">const</span> worker = <span class="hljs-keyword">new</span> <span class="hljs-title class_">AnalyticsWorker</span>();

<span class="hljs-comment">// ?inline - get base64 data URI</span>
<span class="hljs-keyword">import</span> iconDataUri <span class="hljs-keyword">from</span> <span class="hljs-string">'./icon.svg?inline'</span>;
img.<span class="hljs-property">src</span> = iconDataUri;  <span class="hljs-comment">// "data:image/svg+xml;base64,..."</span>
</code></pre><p>These suffixes are Vite-specific. They won't work in Node.js, Webpack, or browsers directly.</p>
<div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"></path></svg>Note</p>
<p>If you migrate from Vite to another bundler, you will need to replace all <code>?url</code>, <code>?raw</code>, and <code>?worker</code> imports with that bundler's equivalents. These are not standard JavaScript.</p>
</div>
<hr />
<h2>The dependency gotcha</h2>
<p><code>?url</code> treats the file as a standalone asset. Vite does <strong>not</strong> process <code>import</code> statements inside it.</p>
<pre><code class="hljs language-js"><span class="hljs-comment">// worker.js</span>
<span class="hljs-keyword">import</span> { helper } <span class="hljs-keyword">from</span> <span class="hljs-string">'./utils.js'</span>;  <span class="hljs-comment">// This import exists</span>

<span class="hljs-comment">// main.js</span>
<span class="hljs-keyword">import</span> workerUrl <span class="hljs-keyword">from</span> <span class="hljs-string">'./worker.js?url'</span>;
<span class="hljs-comment">// ⚠️ worker.js is copied as-is</span>
<span class="hljs-comment">// ⚠️ The import for utils.js is NOT resolved</span>
<span class="hljs-comment">// ⚠️ Worker will fail at runtime with "Failed to resolve module"</span>
</code></pre><table>
<thead>
<tr>
<th>Import Method</th>
<th>Dependencies Resolved?</th>
<th>Tree-shaken?</th>
</tr>
</thead>
<tbody><tr>
<td>Normal import</td>
<td>✅ Yes</td>
<td>✅ Yes</td>
</tr>
<tr>
<td><code>?url</code> import</td>
<td>❌ No</td>
<td>❌ No</td>
</tr>
<tr>
<td><code>?worker</code> import</td>
<td>✅ Yes</td>
<td>✅ Yes</td>
</tr>
</tbody></table>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>If your file has <code>import</code> statements, use <code>?worker</code> instead of <code>?url</code> + manual <code>new Worker()</code>. Vite will bundle the worker and its dependencies correctly.</p>
</div>
<hr />
<h2>Quick reference</h2>
<p><strong>Do you need...</strong></p>
<ul>
<li> The module's API (functions, classes, values) → <strong>Normal import</strong></li>
<li> A URL to pass to browser APIs → <strong><code>?url</code></strong></li>
<li> File contents as a string → <strong><code>?raw</code></strong></li>
<li> A Web Worker with bundled dependencies → <strong><code>?worker</code></strong></li>
</ul>
<pre><code class="hljs language-js"><span class="hljs-comment">// I need jQuery's $ function</span>
<span class="hljs-keyword">import</span> $ <span class="hljs-keyword">from</span> <span class="hljs-string">'jquery'</span>;                           <span class="hljs-comment">// ✅</span>

<span class="hljs-comment">// I need to inject jQuery into an iframe</span>
<span class="hljs-keyword">import</span> jqUrl <span class="hljs-keyword">from</span> <span class="hljs-string">'jquery?url'</span>;                   <span class="hljs-comment">// ✅</span>

<span class="hljs-comment">// I need GLSL shader source code</span>
<span class="hljs-keyword">import</span> frag <span class="hljs-keyword">from</span> <span class="hljs-string">'./shader.frag?raw'</span>;             <span class="hljs-comment">// ✅</span>

<span class="hljs-comment">// I need a worker that imports other modules</span>
<span class="hljs-keyword">import</span> <span class="hljs-title class_">MyWorker</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./worker.js?worker'</span>;        <span class="hljs-comment">// ✅</span>
</code></pre><p><code>?url</code> flips Vite into asset mode. One import gives you the module, the other gives you the address.</p>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2025/switching-from-webpack-to-vite-in-2025/">Switching from Webpack to Vite in 2025</a> - Why Vite's architecture makes it faster</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[jQuery 4: I Can't Believe They Actually Did It]]></title>
      <link>https://kahwee.com/2026/jquery-4-slim-build/</link>
      <guid isPermaLink="true">https://kahwee.com/2026/jquery-4-slim-build/</guid>
      <pubDate>Mon, 19 Jan 2026 17:00:00 GMT</pubDate>
      <description><![CDATA[jQuery 4.0.0 shipped after a decade. The slim build drops AJAX, effects, and Deferreds because the browser finally caught up. Here's what changed and why it matters.]]></description>
      <category>javascript</category>
      <category>jquery</category>
      <category>web-development</category>
      <content:encoded><![CDATA[<p>jQuery 4.0.0 shipped last week. I didn't expect to write about jQuery in 2026.</p>
<p>The project has been around since 2006. Twenty years. Most JavaScript libraries don't survive five. jQuery made it to version 4 after a decade of "you don't need jQuery" blog posts.</p>
<h2>What Changed</h2>
<p>jQuery 4 drops IE support entirely. No IE10, no IE11. This unlocks everything else.</p>
<p>Without legacy browser baggage, the team cut code that existed purely for compatibility:</p>
<ul>
<li>Browser detection hacks for IE quirks</li>
<li>Event handling workarounds for old DOM APIs</li>
<li>Animation fallbacks for browsers without CSS transitions</li>
<li>jQuery.isArray() and jQuery.isFunction() (use native Array.isArray() and typeof instead)</li>
</ul>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>If your app still needs IE11, stay on jQuery 3.x. jQuery 4 will break.</p>
</div>
<p>The slim build also got slimmer. It now removes Deferreds, Callbacks, and the queue module on top of AJAX and effects.</p>
<h2>jQuery Admits the Browser Won</h2>
<p>What slim removes and what replaces it:</p>
<table>
<thead>
<tr>
<th>Removed</th>
<th>Native Replacement</th>
</tr>
</thead>
<tbody><tr>
<td><code>$.ajax()</code>, <code>$.get()</code>, <code>$.post()</code></td>
<td><code>fetch()</code></td>
</tr>
<tr>
<td><code>.fadeIn()</code>, <code>.fadeOut()</code>, <code>.animate()</code></td>
<td>CSS transitions, Web Animations API</td>
</tr>
<tr>
<td><code>$.Deferred()</code>, <code>$.when()</code></td>
<td>Native <code>Promise</code>, <code>Promise.all()</code></td>
</tr>
<tr>
<td><code>.queue()</code>, <code>.dequeue()</code></td>
<td><code>async/await</code> chains</td>
</tr>
</tbody></table>
<p>The size difference:</p>
<pre><code class="hljs">jQuery <span class="hljs-number">4.0</span><span class="hljs-number">.0</span> (gzipped)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Full  ████████████████████████████████████████ <span class="hljs-number">27.6</span> KB
Slim  ████████████████████████████░░░░░░░░░░░░ <span class="hljs-number">19.5</span> KB
                                    ↑
                                <span class="hljs-number">8.1</span> KB saved (<span class="hljs-number">29</span>%)
</code></pre><p>8KB doesn't sound like much in 2026. But those 8KB duplicate what browsers do natively. If you already use <code>fetch()</code> and CSS transitions, you're shipping dead code.</p>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>The slim build keeps everything people actually use jQuery for: selectors, DOM manipulation, events, and traversal. It just removes the parts the browser handles better.</p>
</div>
<h2>Slim or Full?</h2>
<p><strong>Use slim</strong> if you already use <code>fetch()</code> and Promises, jQuery only handles DOM and events, or bundle size matters (widgets, WordPress plugins, marketing pages).</p>
<p><strong>Stick with full</strong> if you have a lot of <code>$.ajax()</code> code you're not ready to refactor, or you need the animation queue for sequenced effects.</p>
<h2>The Migration Path</h2>
<p>jQuery 4 doesn't force a rewrite. Migrate gradually:</p>
<ol>
<li>Stop adding new <code>$.ajax()</code> calls. New code uses <code>fetch()</code>.</li>
<li>Stop adding new jQuery animations. New code uses CSS transitions.</li>
<li>Replace <code>$.Deferred()</code> with native <code>Promise</code> when you touch old code.</li>
<li>Eventually swap <code>jquery.min.js</code> for <code>jquery.slim.min.js</code>.</li>
</ol>
<pre><code class="hljs language-js"><span class="hljs-comment">// Old: jQuery AJAX</span>
$.<span class="hljs-title function_">ajax</span>({
  <span class="hljs-attr">url</span>: <span class="hljs-string">'/api/users'</span>,
  <span class="hljs-attr">method</span>: <span class="hljs-string">'GET'</span>,
  <span class="hljs-attr">dataType</span>: <span class="hljs-string">'json'</span>
}).<span class="hljs-title function_">then</span>(<span class="hljs-function"><span class="hljs-params">data</span> =&gt;</span> <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(data));

<span class="hljs-comment">// New: Native fetch</span>
<span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">'/api/users'</span>).<span class="hljs-title function_">then</span>(<span class="hljs-function"><span class="hljs-params">r</span> =&gt;</span> r.<span class="hljs-title function_">json</span>());
</code></pre><pre><code class="hljs language-css"><span class="hljs-comment">/* Old: jQuery fadeOut */</span>
<span class="hljs-comment">/* $('#box').fadeOut(300); */</span>

<span class="hljs-comment">/* New: CSS transition */</span>
.box <span class="hljs-punctuation">{</span>
  opacity<span class="hljs-punctuation">:</span> <span class="hljs-number">1</span>;
  transition<span class="hljs-punctuation">:</span> opacity <span class="hljs-number">0.3</span>s ease;
<span class="hljs-punctuation">}</span>
.box.hidden <span class="hljs-punctuation">{</span>
  opacity<span class="hljs-punctuation">:</span> <span class="hljs-number">0</span>;
<span class="hljs-punctuation">}</span>
</code></pre><p>The slim build doesn't resolve dependencies inside <code>?url</code> imports. If you're loading jQuery via a script URL in an iframe or worker, test thoroughly.</p>
<h2>Twenty Years Later</h2>
<p>We've gone through Angular, React, Vue, Svelte, and countless build tools. jQuery just kept working.</p>
<p>The slim build is jQuery saying "use the platform" for what the platform does well. Keep jQuery for DOM manipulation. Use <code>fetch()</code> for network requests. Use CSS for animations. That's more honest than most libraries manage.</p>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2026/vite-url-import-jquery/">Why jquery.slim.js?url Behaves Differently in Vite</a> - Import suffixes and what they mean</li>
<li><a href="/2025/switching-from-webpack-to-vite-in-2025/">Switching from Webpack to Vite in 2025</a> - Modern build tooling</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Document Your Quirks: Why CLAUDE.md Changes Everything]]></title>
      <link>https://kahwee.com/2025/document-project-quirks/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/document-project-quirks/</guid>
      <pubDate>Sat, 15 Nov 2025 17:00:00 GMT</pubDate>
      <description><![CDATA[Most teams document their architecture. Few document their quirks. Here's why documenting project quirks in CLAUDE.md turns Claude Code's web interface]]></description>
      <category>claude-ai</category>
      <category>developer-tool</category>
      <category>software-engineering</category>
      <category>ai-assistant</category>
      <category>productivity</category>
      <content:encoded><![CDATA[<p>Last week I spent 45 minutes asking Claude Code variations of the same question. "Can you fix the build?" Different wording each time. Different examples. Different error output. Thirty requests over 45 minutes.</p>
<p><strong>I was asking Claude Code to solve the same problem I solve manually every time I work on the project.</strong></p>
<p>Claude Code is the web interface at claude.ai/code. It loads a <code>CLAUDE.md</code> file from your project root at the start of every session.</p>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>Most teams document their architecture, but almost nobody documents their quirks—the gotchas, the edge cases, the "oh right, you have to do it THIS way or it breaks."</p>
</div>
<p>That is where Claude Code becomes a force multiplier.</p>
<h2>Without Quirks Documented</h2>
<p>Claude encounters <code>pnpm typecheck</code> failing from a package directory. Claude asks: "Are you in a monorepo? Are you using pnpm? Where should I run this?" You explain the architecture. Claude fixes it. Next task: same questions, same exploration, same delay.</p>
<h2>With CLAUDE.md</h2>
<p>Claude encounters the same error. Claude reads CLAUDE.md: "pnpm workspace puts binaries at root in node_modules/.bin/. Always run from project root." Claude fixes it immediately, and the next task requires no questions.</p>
<p>The difference is not that Claude is smarter. <strong>You stopped re-teaching Claude the same lesson every session.</strong></p>
<h2>What Goes in CLAUDE.md</h2>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>Document your <strong>quirks</strong>, not your features. Features are obvious. Quirks are invisible until they break.</p>
</div>
<p>A real pnpm monorepo CLAUDE.md:</p>
<pre><code class="hljs language-markdown"><span class="hljs-section">## pnpm Monorepo: Critical Commands</span>

pnpm install (from root - sets up workspace)
cd packages/web &amp;&amp; pnpm install (breaks symlinks)

pnpm typecheck (from root)
cd packages/web &amp;&amp; pnpm typecheck (binaries at root, not in package)

pnpm -C packages/web build (run specific package)
cd packages/web &amp;&amp; pnpm build (different behavior, can fail)

pnpm --filter @workspace/utils test (run package by name)
pnpm --filter utils test (partial matches fail)

Why each matters:
<span class="hljs-bullet">-</span> pnpm workspace symlinks binaries to root node<span class="hljs-emphasis">_modules/.bin/
- Running from package directory breaks PATH lookup
- -C flag changes working directory safely
- --filter requires exact package name from package.json</span>
</code></pre><p>When you open Claude Code and encounter a pnpm error, it reads this CLAUDE.md and knows what went wrong. No hypothesis needed and no questions asked.</p>
<h2>The Multiplier</h2>
<p>You already know these quirks. You solve them manually every time. The difference between wasting 45 minutes and shipping in 5 minutes is not Claude Code's intelligence. <strong>You taught it to remember what you know.</strong></p>
<p>What was a chatbot that asks the same questions every session becomes a development partner with perfect project memory.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Tailwind v4 + pnpm Monorepos: Missing Classes in Shared Components]]></title>
      <link>https://kahwee.com/2025/tailwind-v4-pnpm-monorepo/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/tailwind-v4-pnpm-monorepo/</guid>
      <pubDate>Sat, 15 Nov 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Tailwind v4's automatic detection doesn't see sibling workspace packages. Use @source to fix it.]]></description>
      <category>web-development</category>
      <category>build-tool</category>
      <category>developer-tool</category>
      <content:encoded><![CDATA[<p>Upgraded to Tailwind v4 in a pnpm monorepo? Components in <code>packages/shared-components</code>, app in <code>packages/main-app</code>? Your shared component utilities are not generating.</p>
<p>What you see in the browser:</p>
<pre><code class="hljs language-jsx">&lt;button className=<span class="hljs-string">"text-(--btn-icon) bg-(--btn-bg)"</span> /&gt;
</code></pre><p>The classes do not exist, and the raw function syntax just sits there unstyled.</p>
<h2>Why It Breaks</h2>
<p>Tailwind v4 moved configuration from JS to CSS. The old <code>tailwind.config.js</code> with <code>content:</code> paths is ignored.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>The new automatic detection only scans inside your own package. It does not look at sibling packages in your workspace.</p>
</div>
<h2>Fix: Add @source</h2>
<p>In your app's CSS file (<code>packages/main-app/src/app.css</code>):</p>
<pre><code class="hljs language-css">@import 'tailwindcss';
@source '../shared-components/src<span class="hljs-comment">/**/</span>*.<span class="hljs-punctuation">{</span>js<span class="hljs-punctuation">,</span>ts<span class="hljs-punctuation">,</span>jsx<span class="hljs-punctuation">,</span>tsx<span class="hljs-punctuation">}</span>';

@theme <span class="hljs-punctuation">{</span>
  --btn-icon<span class="hljs-punctuation">:</span> #<span class="hljs-number">222</span>;
  --btn-bg<span class="hljs-punctuation">:</span> #cfcfcf;
  --radius-lg<span class="hljs-punctuation">:</span> <span class="hljs-number">0.5</span>rem;
<span class="hljs-punctuation">}</span>
</code></pre><p>The <code>@source</code> directive tells Tailwind where to find classes in other packages.</p>
<p>Multiple packages? Add multiple directives:</p>
<pre><code class="hljs language-css">@import 'tailwindcss';
@source '../shared-components/src<span class="hljs-comment">/**/</span>*.<span class="hljs-punctuation">{</span>js<span class="hljs-punctuation">,</span>ts<span class="hljs-punctuation">,</span>jsx<span class="hljs-punctuation">,</span>tsx<span class="hljs-punctuation">}</span>';
@source '../shared-utils/src<span class="hljs-comment">/**/</span>*.<span class="hljs-punctuation">{</span>js<span class="hljs-punctuation">,</span>ts<span class="hljs-punctuation">,</span>jsx<span class="hljs-punctuation">,</span>tsx<span class="hljs-punctuation">}</span>';
@source '../shared-hooks/src<span class="hljs-comment">/**/</span>*.<span class="hljs-punctuation">{</span>js<span class="hljs-punctuation">,</span>ts<span class="hljs-punctuation">,</span>jsx<span class="hljs-punctuation">,</span>tsx<span class="hljs-punctuation">}</span>';

@theme <span class="hljs-punctuation">{</span>
  --btn-icon<span class="hljs-punctuation">:</span> #<span class="hljs-number">222</span>;
  --btn-bg<span class="hljs-punctuation">:</span> #cfcfcf;
<span class="hljs-punctuation">}</span>
</code></pre><p>Your component now works:</p>
<pre><code class="hljs language-jsx"><span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Button</span>(<span class="hljs-params">{ children }</span>) {
  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"text-(--btn-icon) bg-(--btn-bg) rounded-(--radius-lg)"</span>&gt;</span>
      {children}
    <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span></span>
  );
}
</code></pre><div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>This took me 3 hours to figure out. The error message gives you zero indication that classes are missing due to monorepo detection issues.</p>
</div>
<h2>What Changed</h2>
<table>
<thead>
<tr>
<th>Tailwind</th>
<th>Config</th>
<th>Monorepo Support</th>
</tr>
</thead>
<tbody><tr>
<td>v3</td>
<td>JS <code>content: []</code> array</td>
<td>Works if you list all paths</td>
</tr>
<tr>
<td>v4</td>
<td>CSS <code>@source</code> directive</td>
<td>Works if you add <code>@source</code></td>
</tr>
</tbody></table>
<p>The path in <code>@source</code> is relative to your CSS file. All theme variables go in <code>@theme</code>.</p>
<div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"></path></svg>Note</p>
<p>If you add a new shared package later, you must add another <code>@source</code> directive. Tailwind v4 will not detect it automatically.</p>
</div>
<h2>References</h2>
<p><a href="https://tailwindcss.com/docs/functions-and-directives#functions" target="_blank" rel="noopener noreferrer nofollow">Tailwind v4 Functions and Directives</a><br /><a href="https://tailwindcss.com/docs/installation" target="_blank" rel="noopener noreferrer nofollow">Tailwind v4 Installation</a></p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[React Router v7 Framework Mode Loaders vs React Query v5 - When You Need Both (And When You Don't)]]></title>
      <link>https://kahwee.com/2025/react-router-v7-loaders-vs-react-query/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/react-router-v7-loaders-vs-react-query/</guid>
      <pubDate>Mon, 10 Nov 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[React Router v7's loaders handle most data fetching needs. Understanding when React Query adds value and when it's unnecessary complexity.]]></description>
      <category>react</category>
      <category>web-development</category>
      <category>technology</category>
      <category>performance</category>
      <content:encoded><![CDATA[<p>React Router v7 framework mode gives you powerful data loading capabilities through loaders. React Query v5 offers sophisticated client-side data management with caching and invalidation. Do you need both? The answer depends on your application's data patterns.</p>
<p>I spent the last six months building applications with both approaches. Here's what I learned about when each makes sense.</p>
<h2>Setting Up React Query in React Router v7</h2>
<p>Before we compare approaches, let's cover how to properly add React Query to a React Router v7 framework mode project. If you're starting with loaders only, skip this section and come back when you need it.</p>
<h3>Installation</h3>
<pre><code class="hljs language-bash">bun add @tanstack/react-query
bun add -D @tanstack/react-query-devtools
</code></pre><h3>Root Component Configuration</h3>
<p>Create a query client and wrap your app with the provider in your root route:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/query-client.ts</span>
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">QueryClient</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> queryClient = <span class="hljs-keyword">new</span> <span class="hljs-title class_">QueryClient</span>({
  <span class="hljs-attr">defaultOptions</span>: {
    <span class="hljs-attr">queries</span>: {
      <span class="hljs-comment">// Consider data fresh for 5 minutes</span>
      <span class="hljs-attr">staleTime</span>: <span class="hljs-number">1000</span> * <span class="hljs-number">60</span> * <span class="hljs-number">5</span>,
      <span class="hljs-comment">// Refetch when user returns to tab</span>
      <span class="hljs-attr">refetchOnWindowFocus</span>: <span class="hljs-literal">true</span>,
      <span class="hljs-comment">// Retry failed requests twice</span>
      <span class="hljs-attr">retry</span>: <span class="hljs-number">2</span>,
      <span class="hljs-comment">// Wait 2 seconds between retries</span>
      <span class="hljs-attr">retryDelay</span>: <span class="hljs-number">2000</span>,
    },
  },
});
</code></pre><pre><code class="hljs language-typescript"><span class="hljs-comment">// app/root.tsx</span>
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">QueryClientProvider</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">ReactQueryDevtools</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query-devtools'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">Links</span>, <span class="hljs-title class_">Meta</span>, <span class="hljs-title class_">Outlet</span>, <span class="hljs-title class_">Scripts</span>, <span class="hljs-title class_">ScrollRestoration</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;
<span class="hljs-keyword">import</span> { queryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'./query-client'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Root</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">html</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">"en"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charSet</span>=<span class="hljs-string">"utf-8"</span> /&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"viewport"</span> <span class="hljs-attr">content</span>=<span class="hljs-string">"width=device-width, initial-scale=1"</span> /&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">Meta</span> /&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">Links</span> /&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">QueryClientProvider</span> <span class="hljs-attr">client</span>=<span class="hljs-string">{queryClient}</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">Outlet</span> /&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">ReactQueryDevtools</span> <span class="hljs-attr">initialIsOpen</span>=<span class="hljs-string">{false}</span> /&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">QueryClientProvider</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">ScrollRestoration</span> /&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">Scripts</span> /&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span></span>
  );
}
</code></pre><h3>SSR Considerations</h3>
<div class="markdown-alert markdown-alert-caution">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z"></path></svg>Caution</p>
<p>Creating the <code>QueryClient</code> at the file root level can leak data between users in SSR. If you're doing complex SSR with user-specific data, create it with <code>useState</code> to avoid sharing cache between requests:</p>
</div>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">import</span> { useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Root</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> [queryClient] = <span class="hljs-title function_">useState</span>(
    <span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">QueryClient</span>({
      <span class="hljs-attr">defaultOptions</span>: {
        <span class="hljs-attr">queries</span>: {
          <span class="hljs-attr">staleTime</span>: <span class="hljs-number">1000</span> * <span class="hljs-number">60</span> * <span class="hljs-number">5</span>,
        },
      },
    })
  );

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">QueryClientProvider</span> <span class="hljs-attr">client</span>=<span class="hljs-string">{queryClient}</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">Outlet</span> /&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">QueryClientProvider</span>&gt;</span></span>
  );
}
</code></pre><p>This creates a new query client instance per request, preventing data leaks between users.</p>
<p>Now let's look at what each approach does well, and when you'd use one over the other.</p>
<h2>What React Router v7 Loaders Do Well</h2>
<p>React Router v7 loaders run on the server (or edge) before rendering. They're part of the framework, so there's no extra dependency. Every route can define a loader that fetches data:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/posts.$id.tsx</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">LoaderFunctionArgs</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;
<span class="hljs-keyword">import</span> { useLoaderData } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;
<span class="hljs-keyword">import</span> { db } <span class="hljs-keyword">from</span> <span class="hljs-string">'~/db'</span>;
<span class="hljs-keyword">import</span> { posts } <span class="hljs-keyword">from</span> <span class="hljs-string">'~/db/schema'</span>;
<span class="hljs-keyword">import</span> { eq } <span class="hljs-keyword">from</span> <span class="hljs-string">'drizzle-orm'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ params }: <span class="hljs-title class_">LoaderFunctionArgs</span></span>) {
  <span class="hljs-keyword">const</span> post = <span class="hljs-keyword">await</span> db.<span class="hljs-property">query</span>.<span class="hljs-property">posts</span>.<span class="hljs-title function_">findFirst</span>({
    <span class="hljs-attr">where</span>: <span class="hljs-title function_">eq</span>(posts.<span class="hljs-property">id</span>, params.<span class="hljs-property">id</span>),
    <span class="hljs-attr">with</span>: {
      <span class="hljs-attr">author</span>: <span class="hljs-literal">true</span>,
      <span class="hljs-attr">comments</span>: {
        <span class="hljs-attr">orderBy</span>: <span class="hljs-function">(<span class="hljs-params">comments, { desc }</span>) =&gt;</span> [<span class="hljs-title function_">desc</span>(comments.<span class="hljs-property">createdAt</span>)],
        <span class="hljs-attr">limit</span>: <span class="hljs-number">10</span>,
      }
    }
  });

  <span class="hljs-keyword">if</span> (!post) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">'Not Found'</span>, { <span class="hljs-attr">status</span>: <span class="hljs-number">404</span> });
  }

  <span class="hljs-keyword">return</span> { post };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Post</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> { post } = useLoaderData&lt;<span class="hljs-keyword">typeof</span> loader&gt;();

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">article</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span>{post.title}<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>By {post.author.name}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>{post.content}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

      <span class="hljs-tag">&lt;<span class="hljs-name">section</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">h2</span>&gt;</span>Comments<span class="hljs-tag">&lt;/<span class="hljs-name">h2</span>&gt;</span>
        {post.comments.map(comment =&gt; (
          <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">key</span>=<span class="hljs-string">{comment.id}</span>&gt;</span>{comment.text}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        ))}
      <span class="hljs-tag">&lt;/<span class="hljs-name">section</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">article</span>&gt;</span></span>
  );
}
</code></pre><p>The data arrives with the initial HTML. No loading states. No client-side fetch waterfalls. Users see content immediately.</p>
<p>What's powerful here is the loader runs before the page renders. The HTML sent to the browser already contains the post data. No spinners. No "loading..." text. Just content.</p>
<h3>Automatic Revalidation After Actions</h3>
<p>Loaders revalidate automatically after actions complete. Submit a form that updates a post? React Router refetches the loader data. This covers most CRUD patterns without manual cache management:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/posts.$id.edit.tsx</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">ActionFunctionArgs</span>, <span class="hljs-title class_">LoaderFunctionArgs</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;
<span class="hljs-keyword">import</span> { redirect } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">Form</span>, useLoaderData } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ params }: <span class="hljs-title class_">LoaderFunctionArgs</span></span>) {
  <span class="hljs-keyword">const</span> post = <span class="hljs-keyword">await</span> db.<span class="hljs-property">query</span>.<span class="hljs-property">posts</span>.<span class="hljs-title function_">findFirst</span>({
    <span class="hljs-attr">where</span>: <span class="hljs-title function_">eq</span>(posts.<span class="hljs-property">id</span>, params.<span class="hljs-property">id</span>)
  });

  <span class="hljs-keyword">if</span> (!post) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">'Not Found'</span>, { <span class="hljs-attr">status</span>: <span class="hljs-number">404</span> });
  <span class="hljs-keyword">return</span> { post };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">action</span>(<span class="hljs-params">{ request, params }: <span class="hljs-title class_">ActionFunctionArgs</span></span>) {
  <span class="hljs-keyword">const</span> formData = <span class="hljs-keyword">await</span> request.<span class="hljs-title function_">formData</span>();
  <span class="hljs-keyword">const</span> title = formData.<span class="hljs-title function_">get</span>(<span class="hljs-string">'title'</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">string</span>;
  <span class="hljs-keyword">const</span> content = formData.<span class="hljs-title function_">get</span>(<span class="hljs-string">'content'</span>) <span class="hljs-keyword">as</span> <span class="hljs-built_in">string</span>;

  <span class="hljs-keyword">await</span> db.<span class="hljs-title function_">update</span>(posts)
    .<span class="hljs-title function_">set</span>({
      title,
      content,
      <span class="hljs-attr">updatedAt</span>: <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>()
    })
    .<span class="hljs-title function_">where</span>(<span class="hljs-title function_">eq</span>(posts.<span class="hljs-property">id</span>, params.<span class="hljs-property">id</span>));

  <span class="hljs-comment">// Redirect back to the post page</span>
  <span class="hljs-comment">// This triggers the loader on posts.$id.tsx to revalidate</span>
  <span class="hljs-keyword">return</span> <span class="hljs-title function_">redirect</span>(<span class="hljs-string">`/posts/<span class="hljs-subst">${params.id}</span>`</span>);
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">EditPost</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> { post } = useLoaderData&lt;<span class="hljs-keyword">typeof</span> loader&gt;();

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">Form</span> <span class="hljs-attr">method</span>=<span class="hljs-string">"post"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">input</span>
        <span class="hljs-attr">name</span>=<span class="hljs-string">"title"</span>
        <span class="hljs-attr">defaultValue</span>=<span class="hljs-string">{post.title}</span>
        <span class="hljs-attr">required</span>
      /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">textarea</span>
        <span class="hljs-attr">name</span>=<span class="hljs-string">"content"</span>
        <span class="hljs-attr">defaultValue</span>=<span class="hljs-string">{post.content}</span>
        <span class="hljs-attr">required</span>
      /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"submit"</span>&gt;</span>Save<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">Form</span>&gt;</span></span>
  );
}
</code></pre><p>Submit the form, the action runs, the redirect happens, and the post page loader refetches. Your UI shows the updated data. No manual invalidation. No cache keys to manage.</p>
<p>This pattern handles most blog posts, product listings, user profiles, and admin panels without React Query.</p>
<h2>When React Router Loaders Are Enough</h2>
<p>Skip React Query if your app matches these patterns:</p>
<h3>1. Traditional Page-Based Navigation</h3>
<p>Users navigate between routes. Each route loads its data. The browser back button works naturally. You don't need client-side cache persistence across route changes.</p>
<p>I built a content management system with 40+ routes using just loaders. Posts list, individual posts, categories, tags, settings pages. Each route fetches what it needs. Navigation is fast because React Router prefetches on hover. No React Query needed.</p>
<h3>2. Simple Mutations with Automatic Revalidation</h3>
<p>Here's a complete example of deleting a comment:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/posts.$postId.comments.$commentId.delete.tsx</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">ActionFunctionArgs</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;
<span class="hljs-keyword">import</span> { redirect } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">action</span>(<span class="hljs-params">{ params }: <span class="hljs-title class_">ActionFunctionArgs</span></span>) {
  <span class="hljs-keyword">await</span> db.<span class="hljs-title function_">delete</span>(comments)
    .<span class="hljs-title function_">where</span>(<span class="hljs-title function_">eq</span>(comments.<span class="hljs-property">id</span>, params.<span class="hljs-property">commentId</span>));

  <span class="hljs-comment">// Redirect back to the post</span>
  <span class="hljs-comment">// The post loader refetches, showing updated comment count</span>
  <span class="hljs-keyword">return</span> <span class="hljs-title function_">redirect</span>(<span class="hljs-string">`/posts/<span class="hljs-subst">${params.postId}</span>`</span>);
}
</code></pre><pre><code class="hljs language-typescript"><span class="hljs-comment">// In your post component</span>
&lt;<span class="hljs-title class_">Form</span> method=<span class="hljs-string">"post"</span> action={<span class="hljs-string">`/posts/<span class="hljs-subst">${post.id}</span>/comments/<span class="hljs-subst">${comment.id}</span>/delete`</span>}&gt;
  <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"submit"</span>&gt;</span>Delete<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span></span>
&lt;/<span class="hljs-title class_">Form</span>&gt;
</code></pre><p>The form submits, the action runs, the redirect triggers the post loader to revalidate. The comment disappears from the list. This pattern works for:</p>
<ul>
<li>Creating posts</li>
<li>Updating user profiles</li>
<li>Deleting items</li>
<li>Bulk operations</li>
</ul>
<p>If your mutation affects data on the current route, loaders handle it automatically.</p>
<h3>3. Data Scoped to Single Routes</h3>
<p>If data lives on one route and doesn't appear elsewhere, loaders handle it perfectly. Each route loads what it needs when users visit.</p>
<p>Settings pages are a good example:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/settings.notifications.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ request }: <span class="hljs-title class_">LoaderFunctionArgs</span></span>) {
  <span class="hljs-keyword">const</span> userId = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getUserId</span>(request);

  <span class="hljs-keyword">const</span> settings = <span class="hljs-keyword">await</span> db.<span class="hljs-property">query</span>.<span class="hljs-property">notificationSettings</span>.<span class="hljs-title function_">findFirst</span>({
    <span class="hljs-attr">where</span>: <span class="hljs-title function_">eq</span>(notificationSettings.<span class="hljs-property">userId</span>, userId)
  });

  <span class="hljs-keyword">return</span> { settings };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">action</span>(<span class="hljs-params">{ request }: <span class="hljs-title class_">ActionFunctionArgs</span></span>) {
  <span class="hljs-keyword">const</span> userId = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getUserId</span>(request);
  <span class="hljs-keyword">const</span> formData = <span class="hljs-keyword">await</span> request.<span class="hljs-title function_">formData</span>();

  <span class="hljs-keyword">await</span> db.<span class="hljs-title function_">update</span>(notificationSettings)
    .<span class="hljs-title function_">set</span>({
      <span class="hljs-attr">emailNotifications</span>: formData.<span class="hljs-title function_">get</span>(<span class="hljs-string">'email'</span>) === <span class="hljs-string">'on'</span>,
      <span class="hljs-attr">pushNotifications</span>: formData.<span class="hljs-title function_">get</span>(<span class="hljs-string">'push'</span>) === <span class="hljs-string">'on'</span>,
    })
    .<span class="hljs-title function_">where</span>(<span class="hljs-title function_">eq</span>(notificationSettings.<span class="hljs-property">userId</span>, userId));

  <span class="hljs-keyword">return</span> { <span class="hljs-attr">success</span>: <span class="hljs-literal">true</span> };
}
</code></pre><p>These settings only appear on this route. No other component needs them. Loaders are perfect here.</p>
<h3>4. Server-Heavy Applications</h3>
<p>If your app renders mostly on the server and JavaScript is progressive enhancement, loaders give you server-side data fetching with zero client bundle impact.</p>
<p>React Query adds 40KB+ to your bundle. If you're building a documentation site, blog, or marketing pages with occasional interactivity, loaders keep your bundle small while providing excellent data loading.</p>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>Check your network tab before adding React Query. If you don't see duplicate API calls or repeated fetches of the same data, loaders are doing their job.</p>
</div>
<h2>The Breaking Points: When Loaders Become Insufficient</h2>
<p>Here are the specific moments where React Router loaders hit their limits and you'll know you need React Query:</p>
<h3>1. No Caching Between Navigations</h3>
<p>Loaders only keep data for the current route. Click a link, the loader runs. Hit the back button, the loader runs again. Every navigation refetches everything.</p>
<p>This becomes painful when:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// User journey:</span>
<span class="hljs-comment">// 1. Visit /posts - loader fetches posts list (200 posts, 50KB response)</span>
<span class="hljs-comment">// 2. Click post #42 - loader fetches post details</span>
<span class="hljs-comment">// 3. Hit back button - loader fetches posts list AGAIN (same 50KB)</span>
<span class="hljs-comment">// 4. Click post #17 - loader fetches post details</span>
<span class="hljs-comment">// 5. Hit back button - loader fetches posts list AGAIN</span>
</code></pre><p>You just downloaded the same 50KB posts list three times in one minute. Users on slow connections see loading states every time they navigate back.</p>
<p>React Query caches this. Navigate away, come back, see cached data instantly while React Query refetches in the background. The perceived performance difference is massive.</p>
<h3>2. Data Used in Multiple Places</h3>
<p>A header component needs the current user. A sidebar needs the current user. The settings page needs the current user. With loaders, your options are:</p>
<p><strong>Option A: Fetch in every route's loader (wasteful)</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// Every route that needs user data</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ request }: <span class="hljs-title class_">LoaderFunctionArgs</span></span>) {
  <span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getUser</span>(request); <span class="hljs-comment">// Database query every time</span>
  <span class="hljs-keyword">const</span> posts = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getPosts</span>(); <span class="hljs-comment">// What this route actually needs</span>
  <span class="hljs-keyword">return</span> { user, posts };
}
</code></pre><p>You're making database queries for the same user data on every route. Slow. Expensive if you're paying per query.</p>
<p><strong>Option B: Fetch in root loader, pass down via context (rigid)</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/root.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ request }: <span class="hljs-title class_">LoaderFunctionArgs</span></span>) {
  <span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getUser</span>(request);
  <span class="hljs-keyword">return</span> { user };
}

<span class="hljs-comment">// Now every component needs the context or props</span>
</code></pre><p>This works but you can't granularly control when user data refetches. If user updates their profile on a different route, how do you invalidate the root loader data? You're stuck with full page reloads or complex invalidation logic.</p>
<p><strong>Option C: React Query (flexible)</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// Fetch once, use everywhere, granular invalidation</span>
<span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: user } = <span class="hljs-title function_">useUser</span>();
</code></pre><p>Cache, share, invalidate independently. This is the pattern React Query was built for.</p>
<h3>3. Polling and Real-Time Updates</h3>
<p>Dashboard showing live metrics? Stock prices? Order status? Loaders run when you navigate to the route, then stop. They don't poll. They don't refetch in the background.</p>
<p>You'd need to build your own polling mechanism:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// With loaders only</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Dashboard</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> initialData = useLoaderData&lt;<span class="hljs-keyword">typeof</span> loader&gt;();
  <span class="hljs-keyword">const</span> [data, setData] = <span class="hljs-title function_">useState</span>(initialData);

  <span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">const</span> interval = <span class="hljs-built_in">setInterval</span>(<span class="hljs-title function_">async</span> () =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">'/api/metrics'</span>);
      <span class="hljs-keyword">const</span> newData = <span class="hljs-keyword">await</span> response.<span class="hljs-title function_">json</span>();
      <span class="hljs-title function_">setData</span>(newData);
    }, <span class="hljs-number">30_000</span>);

    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">clearInterval</span>(interval);
  }, []);

  <span class="hljs-keyword">return</span> <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>Active Users: {data.activeUsers}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>;
}
</code></pre><p>You just reimplemented React Query's <code>refetchInterval</code> feature. Badly. No deduplication if multiple components poll the same endpoint. No automatic cleanup. No error handling.</p>
<p>React Query handles this:</p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">const</span> { data } = <span class="hljs-title function_">useQuery</span>({
  <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'metrics'</span>],
  <span class="hljs-attr">queryFn</span>: fetchMetrics,
  <span class="hljs-attr">refetchInterval</span>: <span class="hljs-number">30_000</span>,
});
</code></pre><p>When your app needs data to update automatically without user interaction, loaders can't help.</p>
<h3>4. You're Fetching the Same Data Repeatedly</h3>
<p>Look at your network tab. Are you seeing the same API calls over and over with identical responses? You're wasting:</p>
<ul>
<li>User bandwidth (expensive on mobile)</li>
<li>Server resources (database queries, API calls)</li>
<li>User time (waiting for data they already saw)</li>
</ul>
<p>I had a CMS where the posts list endpoint was called 15 times in 2 minutes of normal usage. Same 200 posts, same 50KB response. Adding React Query with a 5-minute stale time cut that to 1 request. The server CPU usage dropped 40%.</p>
<h3>5. Mutations Affecting Multiple Routes</h3>
<p>You create a post on <code>/posts/new</code>. The posts list lives on <code>/posts</code>. Your user profile shows post count on <code>/profile</code>. The home page shows recent posts on <code>/</code>.</p>
<p>With loaders, you need to manually revalidate all affected routes:</p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">const</span> fetcher = <span class="hljs-title function_">useFetcher</span>();

<span class="hljs-comment">// After creating a post, revalidate everything</span>
fetcher.<span class="hljs-title function_">load</span>(<span class="hljs-string">'/posts'</span>);
fetcher.<span class="hljs-title function_">load</span>(<span class="hljs-string">'/profile'</span>);
fetcher.<span class="hljs-title function_">load</span>(<span class="hljs-string">'/'</span>);
</code></pre><p>Miss one and your UI shows stale data. Add a new route that shows posts? Remember to revalidate it everywhere posts get created, updated, or deleted.</p>
<p>React Query's invalidation handles this cleanly:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// Invalidate all queries with 'posts' in the key</span>
queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>] });
</code></pre><p>Every component using post data refetches. Automatically. No matter where in the app.</p>
<h3>6. Optimistic Updates Are Critical</h3>
<p>Your app has like buttons, follow buttons, todo toggles, or any interaction where waiting for the server feels slow. Loaders can't do optimistic updates. They're about loading data when you navigate, not updating data instantly on interaction.</p>
<p>You'd need to track optimistic state manually:</p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">const</span> [optimisticLikes, setOptimisticLikes] = useState&lt;<span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">number</span>&gt;&gt;({});

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">handleLike</span>(<span class="hljs-params"><span class="hljs-attr">postId</span>: <span class="hljs-built_in">string</span>, <span class="hljs-attr">currentLikes</span>: <span class="hljs-built_in">number</span></span>) {
  <span class="hljs-comment">// Set optimistic state</span>
  <span class="hljs-title function_">setOptimisticLikes</span>(<span class="hljs-function"><span class="hljs-params">prev</span> =&gt;</span> ({ ...prev, [postId]: currentLikes + <span class="hljs-number">1</span> }));

  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`/api/posts/<span class="hljs-subst">${postId}</span>/like`</span>, { <span class="hljs-attr">method</span>: <span class="hljs-string">'POST'</span> });
    <span class="hljs-comment">// Revalidate loader</span>
    <span class="hljs-title function_">submit</span>({}, { <span class="hljs-attr">method</span>: <span class="hljs-string">'get'</span> });
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-comment">// Roll back on error</span>
    <span class="hljs-title function_">setOptimisticLikes</span>(<span class="hljs-function"><span class="hljs-params">prev</span> =&gt;</span> {
      <span class="hljs-keyword">const</span> next = { ...prev };
      <span class="hljs-keyword">delete</span> next[postId];
      <span class="hljs-keyword">return</span> next;
    });
  }
}
</code></pre><p>Complex. Error-prone. React Query's <code>onMutate</code> and rollback handling make this pattern simple and reliable.</p>
<p>If you recognize your app in any of these scenarios, you need React Query. Loaders alone won't cut it.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Don't build your own polling, caching, or optimistic update system on top of loaders. You'll end up reimplementing React Query poorly.</p>
</div>
<h2>When You Need React Query</h2>
<p>React Query becomes valuable when you have these requirements:</p>
<h3>1. Shared Data Across Multiple Components</h3>
<p>A user profile appears in the header, sidebar, and settings page. With just loaders, you'd either:</p>
<ul>
<li>Fetch the same data three times on different routes</li>
<li>Pass props through many layers (prop drilling hell)</li>
<li>Use React context (which doesn't solve stale data problems)</li>
</ul>
<p>React Query caches by query key. Fetch once, use everywhere:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/hooks/use-user.ts</span>
<span class="hljs-keyword">import</span> { useQuery } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">User</span> {
  <span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">name</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">email</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">avatarUrl</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">role</span>: <span class="hljs-string">'admin'</span> | <span class="hljs-string">'user'</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">useUser</span>(<span class="hljs-params"><span class="hljs-attr">userId</span>: <span class="hljs-built_in">string</span></span>) {
  <span class="hljs-keyword">return</span> <span class="hljs-title function_">useQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'user'</span>, userId],
    <span class="hljs-attr">queryFn</span>: <span class="hljs-title function_">async</span> (): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">User</span>&gt; =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`/api/users/<span class="hljs-subst">${userId}</span>`</span>);
      <span class="hljs-keyword">if</span> (!response.<span class="hljs-property">ok</span>) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">'Failed to fetch user'</span>);
      <span class="hljs-keyword">return</span> response.<span class="hljs-title function_">json</span>();
    },
    <span class="hljs-attr">staleTime</span>: <span class="hljs-number">60_000</span>, <span class="hljs-comment">// Consider fresh for 1 minute</span>
  });
}
</code></pre><p>Now use it anywhere:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/components/header.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Header</span>(<span class="hljs-params">{ userId }: { userId: <span class="hljs-built_in">string</span> }</span>) {
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: user } = <span class="hljs-title function_">useUser</span>(userId);

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">header</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">img</span> <span class="hljs-attr">src</span>=<span class="hljs-string">{user?.avatarUrl}</span> <span class="hljs-attr">alt</span>=<span class="hljs-string">{user?.name}</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">span</span>&gt;</span>{user?.name}<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">header</span>&gt;</span></span>
  );
}

<span class="hljs-comment">// app/components/sidebar.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Sidebar</span>(<span class="hljs-params">{ userId }: { userId: <span class="hljs-built_in">string</span> }</span>) {
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: user } = <span class="hljs-title function_">useUser</span>(userId);

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">aside</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>{user?.name}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>{user?.email}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>Role: {user?.role}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">aside</span>&gt;</span></span>
  );
}

<span class="hljs-comment">// app/routes/settings.profile.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">ProfileSettings</span>(<span class="hljs-params">{ userId }: { userId: <span class="hljs-built_in">string</span> }</span>) {
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: user, isLoading } = <span class="hljs-title function_">useUser</span>(userId);

  <span class="hljs-keyword">if</span> (isLoading) <span class="hljs-keyword">return</span> <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>Loading...<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>;

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">form</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">defaultValue</span>=<span class="hljs-string">{user?.name}</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">defaultValue</span>=<span class="hljs-string">{user?.email}</span> /&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">form</span>&gt;</span></span>
  );
}
</code></pre><p>All three components call <code>useUser(userId)</code>. React Query makes only one network request. All components share the cached result. Update the user? One invalidation updates all three.</p>
<p>This pattern saved me from prop drilling through 5+ component layers in a dashboard application.</p>
<h3>2. Background Refetching and Stale-While-Revalidate</h3>
<p>React Query can refetch data in the background while showing cached data. Users see instant responses while you keep data fresh:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// Real-time dashboard showing live metrics</span>
<span class="hljs-keyword">function</span> <span class="hljs-title function_">DashboardMetrics</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: metrics, dataUpdatedAt } = <span class="hljs-title function_">useQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'metrics'</span>],
    <span class="hljs-attr">queryFn</span>: <span class="hljs-title function_">async</span> () =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">'/api/metrics'</span>);
      <span class="hljs-keyword">return</span> response.<span class="hljs-title function_">json</span>();
    },
    <span class="hljs-attr">staleTime</span>: <span class="hljs-number">30_000</span>, <span class="hljs-comment">// Consider fresh for 30 seconds</span>
    <span class="hljs-attr">refetchInterval</span>: <span class="hljs-number">60_000</span>, <span class="hljs-comment">// Refetch every minute</span>
    <span class="hljs-attr">refetchOnWindowFocus</span>: <span class="hljs-literal">true</span>, <span class="hljs-comment">// Refetch when user returns to tab</span>
  });

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h2</span>&gt;</span>Live Metrics<span class="hljs-tag">&lt;/<span class="hljs-name">h2</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Active Users: {metrics?.activeUsers}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Revenue Today: ${metrics?.revenueToday}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Orders: {metrics?.ordersCount}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">small</span>&gt;</span>Last updated: {new Date(dataUpdatedAt).toLocaleTimeString()}<span class="hljs-tag">&lt;/<span class="hljs-name">small</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>
  );
}
</code></pre><p>The component shows cached data instantly, then refetches in the background. Users never see a loading spinner after the initial load. The UI stays responsive while data updates.</p>
<p>This pattern doesn't exist in React Router loaders. Loaders fetch when you navigate to the route, not on a timer. If you need polling or real-time updates, you need React Query.</p>
<h3>3. Optimistic Updates</h3>
<p>Update the UI immediately while the mutation runs in the background. This makes your app feel instant:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/components/like-button.tsx</span>
<span class="hljs-keyword">import</span> { useMutation, useQueryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">Post</span> {
  <span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">title</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">likes</span>: <span class="hljs-built_in">number</span>;
  <span class="hljs-attr">likedByMe</span>: <span class="hljs-built_in">boolean</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">LikeButton</span>(<span class="hljs-params">{ post }: { post: Post }</span>) {
  <span class="hljs-keyword">const</span> queryClient = <span class="hljs-title function_">useQueryClient</span>();

  <span class="hljs-keyword">const</span> likeMutation = <span class="hljs-title function_">useMutation</span>({
    <span class="hljs-attr">mutationFn</span>: <span class="hljs-title function_">async</span> (<span class="hljs-attr">postId</span>: <span class="hljs-built_in">string</span>) =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`/api/posts/<span class="hljs-subst">${postId}</span>/like`</span>, {
        <span class="hljs-attr">method</span>: <span class="hljs-string">'POST'</span>,
      });
      <span class="hljs-keyword">if</span> (!response.<span class="hljs-property">ok</span>) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">'Failed to like post'</span>);
      <span class="hljs-keyword">return</span> response.<span class="hljs-title function_">json</span>();
    },

    <span class="hljs-comment">// Run before the mutation starts</span>
    <span class="hljs-attr">onMutate</span>: <span class="hljs-title function_">async</span> (postId) =&gt; {
      <span class="hljs-comment">// Cancel any outgoing refetches</span>
      <span class="hljs-keyword">await</span> queryClient.<span class="hljs-title function_">cancelQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, postId] });

      <span class="hljs-comment">// Snapshot the previous value</span>
      <span class="hljs-keyword">const</span> previousPost = queryClient.<span class="hljs-property">getQueryData</span>&lt;<span class="hljs-title class_">Post</span>&gt;([<span class="hljs-string">'posts'</span>, postId]);

      <span class="hljs-comment">// Optimistically update the cache</span>
      queryClient.<span class="hljs-property">setQueryData</span>&lt;<span class="hljs-title class_">Post</span>&gt;([<span class="hljs-string">'posts'</span>, postId], <span class="hljs-function">(<span class="hljs-params">old</span>) =&gt;</span> {
        <span class="hljs-keyword">if</span> (!old) <span class="hljs-keyword">return</span> old;
        <span class="hljs-keyword">return</span> {
          ...old,
          <span class="hljs-attr">likes</span>: old.<span class="hljs-property">likedByMe</span> ? old.<span class="hljs-property">likes</span> - <span class="hljs-number">1</span> : old.<span class="hljs-property">likes</span> + <span class="hljs-number">1</span>,
          <span class="hljs-attr">likedByMe</span>: !old.<span class="hljs-property">likedByMe</span>,
        };
      });

      <span class="hljs-comment">// Return context with the previous value</span>
      <span class="hljs-keyword">return</span> { previousPost };
    },

    <span class="hljs-comment">// If mutation fails, rollback</span>
    <span class="hljs-attr">onError</span>: <span class="hljs-function">(<span class="hljs-params">err, postId, context</span>) =&gt;</span> {
      <span class="hljs-keyword">if</span> (context?.<span class="hljs-property">previousPost</span>) {
        queryClient.<span class="hljs-title function_">setQueryData</span>([<span class="hljs-string">'posts'</span>, postId], context.<span class="hljs-property">previousPost</span>);
      }
    },

    <span class="hljs-comment">// Always refetch after error or success</span>
    <span class="hljs-attr">onSettled</span>: <span class="hljs-function">(<span class="hljs-params">data, error, postId</span>) =&gt;</span> {
      queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, postId] });
    },
  });

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">button</span>
      <span class="hljs-attr">onClick</span>=<span class="hljs-string">{()</span> =&gt;</span> likeMutation.mutate(post.id)}
      disabled={likeMutation.isPending}
    &gt;
      {post.likedByMe ? '❤️' : '🤍'} {post.likes}
    <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span></span>
  );
}
</code></pre><p>Click the button. The heart fills and the count updates instantly. The mutation runs in the background. If it fails, React Query rolls back automatically. This creates a much faster perceived experience than waiting for server responses.</p>
<p>I use this pattern for:</p>
<ul>
<li>Like/favorite buttons</li>
<li>Follow/unfollow actions</li>
<li>Quick edits (updating post titles, comment text)</li>
<li>Todo item toggles</li>
<li>Cart operations</li>
</ul>
<h3>4. Infinite Scroll and Pagination</h3>
<p>React Query has built-in infinite query support:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/components/posts-feed.tsx</span>
<span class="hljs-keyword">import</span> { useInfiniteQuery } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;
<span class="hljs-keyword">import</span> { useInView } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-intersection-observer'</span>;

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">Post</span> {
  <span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">title</span>: <span class="hljs-built_in">string</span>;
  <span class="hljs-attr">excerpt</span>: <span class="hljs-built_in">string</span>;
}

<span class="hljs-keyword">interface</span> <span class="hljs-title class_">PostsPage</span> {
  <span class="hljs-attr">posts</span>: <span class="hljs-title class_">Post</span>[];
  <span class="hljs-attr">nextCursor</span>: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">PostsFeed</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> { ref, inView } = <span class="hljs-title function_">useInView</span>();

  <span class="hljs-keyword">const</span> {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = <span class="hljs-title function_">useInfiniteQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, <span class="hljs-string">'feed'</span>],
    <span class="hljs-attr">queryFn</span>: <span class="hljs-title function_">async</span> ({ pageParam }): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">PostsPage</span>&gt; =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(
        <span class="hljs-string">`/api/posts?cursor=<span class="hljs-subst">${pageParam ?? <span class="hljs-string">''</span>}</span>`</span>
      );
      <span class="hljs-keyword">return</span> response.<span class="hljs-title function_">json</span>();
    },
    <span class="hljs-attr">getNextPageParam</span>: <span class="hljs-function">(<span class="hljs-params">lastPage</span>) =&gt;</span> lastPage.<span class="hljs-property">nextCursor</span>,
    <span class="hljs-attr">initialPageParam</span>: <span class="hljs-literal">undefined</span>,
  });

  <span class="hljs-comment">// Fetch next page when bottom element comes into view</span>
  <span class="hljs-title class_">React</span>.<span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">if</span> (inView &amp;&amp; hasNextPage) {
      <span class="hljs-title function_">fetchNextPage</span>();
    }
  }, [inView, hasNextPage, fetchNextPage]);

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
      {data?.pages.map((page) =&gt; (
        page.posts.map((post) =&gt; (
          <span class="hljs-tag">&lt;<span class="hljs-name">article</span> <span class="hljs-attr">key</span>=<span class="hljs-string">{post.id}</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">h2</span>&gt;</span>{post.title}<span class="hljs-tag">&lt;/<span class="hljs-name">h2</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>{post.excerpt}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
          <span class="hljs-tag">&lt;/<span class="hljs-name">article</span>&gt;</span>
        ))
      ))}

      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">ref</span>=<span class="hljs-string">{ref}</span>&gt;</span>
        {isFetchingNextPage ? 'Loading more...' : null}
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>
  );
}
</code></pre><p>Scroll to the bottom, React Query loads the next page. The data accumulates in memory. Scroll back up? The data is still there. This provides a smooth infinite scroll experience.</p>
<p>You can implement pagination with loaders using URL search params:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/posts._index.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ request }: <span class="hljs-title class_">LoaderFunctionArgs</span></span>) {
  <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>);
  <span class="hljs-keyword">const</span> page = <span class="hljs-built_in">parseInt</span>(url.<span class="hljs-property">searchParams</span>.<span class="hljs-title function_">get</span>(<span class="hljs-string">'page'</span>) ?? <span class="hljs-string">'1'</span>);

  <span class="hljs-keyword">const</span> posts = <span class="hljs-keyword">await</span> db.<span class="hljs-property">query</span>.<span class="hljs-property">posts</span>.<span class="hljs-title function_">findMany</span>({
    <span class="hljs-attr">limit</span>: <span class="hljs-number">20</span>,
    <span class="hljs-attr">offset</span>: (page - <span class="hljs-number">1</span>) * <span class="hljs-number">20</span>,
  });

  <span class="hljs-keyword">return</span> { posts, page };
}
</code></pre><p>But infinite scroll requires client-side data accumulation. React Query handles this elegantly while loaders don't.</p>
<h3>5. Cross-Route Data Dependencies</h3>
<p>If creating a post should update the posts list, and both live on different routes, you need coordinated invalidation. React Query handles this elegantly:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/posts.new.tsx</span>
<span class="hljs-keyword">import</span> { useMutation, useQueryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;
<span class="hljs-keyword">import</span> { useNavigate } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">NewPost</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> navigate = <span class="hljs-title function_">useNavigate</span>();
  <span class="hljs-keyword">const</span> queryClient = <span class="hljs-title function_">useQueryClient</span>();

  <span class="hljs-keyword">const</span> createMutation = <span class="hljs-title function_">useMutation</span>({
    <span class="hljs-attr">mutationFn</span>: <span class="hljs-title function_">async</span> (<span class="hljs-attr">post</span>: { <span class="hljs-attr">title</span>: <span class="hljs-built_in">string</span>; <span class="hljs-attr">content</span>: <span class="hljs-built_in">string</span> }) =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">'/api/posts'</span>, {
        <span class="hljs-attr">method</span>: <span class="hljs-string">'POST'</span>,
        <span class="hljs-attr">headers</span>: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span> },
        <span class="hljs-attr">body</span>: <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(post),
      });
      <span class="hljs-keyword">return</span> response.<span class="hljs-title function_">json</span>();
    },

    <span class="hljs-attr">onSuccess</span>: <span class="hljs-function">(<span class="hljs-params">data</span>) =&gt;</span> {
      <span class="hljs-comment">// Invalidate the posts list on another route</span>
      queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, <span class="hljs-string">'list'</span>] });

      <span class="hljs-comment">// Invalidate the feed</span>
      queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, <span class="hljs-string">'feed'</span>] });

      <span class="hljs-comment">// Navigate to the new post</span>
      <span class="hljs-title function_">navigate</span>(<span class="hljs-string">`/posts/<span class="hljs-subst">${data.id}</span>`</span>);
    },
  });

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">form</span> <span class="hljs-attr">onSubmit</span>=<span class="hljs-string">{(e)</span> =&gt;</span> {
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      createMutation.mutate({
        title: formData.get('title') as string,
        content: formData.get('content') as string,
      });
    }}&gt;
      <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"title"</span> <span class="hljs-attr">required</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">textarea</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"content"</span> <span class="hljs-attr">required</span> /&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"submit"</span>&gt;</span>Create Post<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">form</span>&gt;</span></span>
  );
}
</code></pre><p>Create a post on the <code>/posts/new</code> route. Navigate to <code>/posts</code>. The list includes your new post immediately. React Query invalidated the cached list data.</p>
<p>With just loaders, you'd need to manually trigger revalidation:</p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">const</span> fetcher = <span class="hljs-title function_">useFetcher</span>();

<span class="hljs-comment">// After creating, manually revalidate the posts list</span>
fetcher.<span class="hljs-title function_">load</span>(<span class="hljs-string">'/posts'</span>);
</code></pre><p>It works, but React Query's invalidation is more ergonomic when you have complex data dependencies across routes.</p>
<h2>How Query Invalidation Works</h2>
<p>React Query's invalidation system is the most compelling reason to use it. Here's how it works in detail:</p>
<h3>Mark Queries as Stale</h3>
<pre><code class="hljs language-typescript">queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>] });
</code></pre><p>This marks all queries with that key as stale. If a component is currently using this query and it's mounted, React Query refetches immediately. If no component is using the query, it refetches next time a component mounts.</p>
<p>The refetch happens in the background. Users see cached data while fresh data loads.</p>
<h3>Invalidate Related Queries</h3>
<p>The query key array structure lets you invalidate broadly or surgically:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// Invalidate ALL posts queries</span>
<span class="hljs-comment">// Matches: ['posts'], ['posts', 'list'], ['posts', '123'], etc.</span>
queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>] });

<span class="hljs-comment">// Invalidate only the posts list</span>
<span class="hljs-comment">// Matches: ['posts', 'list'] but not ['posts', '123']</span>
queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, <span class="hljs-string">'list'</span>], <span class="hljs-attr">exact</span>: <span class="hljs-literal">true</span> });

<span class="hljs-comment">// Invalidate a specific post</span>
<span class="hljs-comment">// Matches: ['posts', '123'] but not ['posts', 'list'] or ['posts', '456']</span>
queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, <span class="hljs-string">'123'</span>] });

<span class="hljs-comment">// Invalidate all posts for a specific user</span>
<span class="hljs-comment">// Matches: ['posts', { authorId: 'user123' }]</span>
queryClient.<span class="hljs-title function_">invalidateQueries</span>({
  <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>],
  <span class="hljs-attr">predicate</span>: <span class="hljs-function">(<span class="hljs-params">query</span>) =&gt;</span> {
    <span class="hljs-keyword">const</span> [, filters] = query.<span class="hljs-property">queryKey</span>;
    <span class="hljs-keyword">return</span> filters?.<span class="hljs-property">authorId</span> === <span class="hljs-string">'user123'</span>;
  }
});
</code></pre><p>This hierarchical key structure is powerful. I structure query keys like this:</p>
<pre><code class="hljs language-typescript">[<span class="hljs-string">'posts'</span>] <span class="hljs-comment">// All posts</span>
[<span class="hljs-string">'posts'</span>, <span class="hljs-string">'list'</span>] <span class="hljs-comment">// Posts list</span>
[<span class="hljs-string">'posts'</span>, <span class="hljs-string">'list'</span>, { <span class="hljs-attr">filter</span>: <span class="hljs-string">'published'</span> }] <span class="hljs-comment">// Filtered list</span>
[<span class="hljs-string">'posts'</span>, postId] <span class="hljs-comment">// Individual post</span>
[<span class="hljs-string">'posts'</span>, postId, <span class="hljs-string">'comments'</span>] <span class="hljs-comment">// Post comments</span>
[<span class="hljs-string">'user'</span>, userId] <span class="hljs-comment">// User profile</span>
[<span class="hljs-string">'user'</span>, userId, <span class="hljs-string">'posts'</span>] <span class="hljs-comment">// User's posts</span>
</code></pre><p>Now I can invalidate precisely:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// User updates their profile</span>
queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'user'</span>, userId] });

<span class="hljs-comment">// User publishes a post - update their posts list</span>
queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'user'</span>, userId, <span class="hljs-string">'posts'</span>] });

<span class="hljs-comment">// Someone comments on a post - update just the comments</span>
queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, postId, <span class="hljs-string">'comments'</span>] });
</code></pre><h3>Automatic Refetch on Window Focus</h3>
<pre><code class="hljs language-typescript"><span class="hljs-title function_">useQuery</span>({
  <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>],
  <span class="hljs-attr">queryFn</span>: fetchPosts,
  <span class="hljs-attr">refetchOnWindowFocus</span>: <span class="hljs-literal">true</span>, <span class="hljs-comment">// Default behavior</span>
  <span class="hljs-attr">refetchOnReconnect</span>: <span class="hljs-literal">true</span>, <span class="hljs-comment">// Also refetch when reconnecting to network</span>
});
</code></pre><p>User switches tabs, comes back 10 minutes later? React Query refetches. This keeps data fresh without manual intervention.</p>
<p>I disable this for data that changes rarely:</p>
<pre><code class="hljs language-typescript"><span class="hljs-title function_">useQuery</span>({
  <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'site-config'</span>],
  <span class="hljs-attr">queryFn</span>: fetchConfig,
  <span class="hljs-attr">refetchOnWindowFocus</span>: <span class="hljs-literal">false</span>, <span class="hljs-comment">// Site config rarely changes</span>
  <span class="hljs-attr">staleTime</span>: <span class="hljs-title class_">Infinity</span>, <span class="hljs-comment">// Never consider stale</span>
});
</code></pre><h2>The Hybrid Approach: Using Both Together</h2>
<p>The most powerful pattern combines both: loaders for fast initial page loads, React Query for sophisticated client-side data management. This gives you server-rendered HTML with instant data, plus all of React Query's features for updates, caching, and invalidation.</p>
<h3>The Pattern: Loaders Pre-Fill React Query Cache</h3>
<p>Instead of using loader data directly, use loaders to populate React Query's cache before the component renders. Then use <code>useQuery</code> in components as normal:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/posts.$id.tsx</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">LoaderFunctionArgs</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;
<span class="hljs-keyword">import</span> { useParams } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;
<span class="hljs-keyword">import</span> { useQuery } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;
<span class="hljs-keyword">import</span> { queryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'~/query-client'</span>;

<span class="hljs-comment">// Loader runs on the server, fetches data, pre-fills React Query cache</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ params }: <span class="hljs-title class_">LoaderFunctionArgs</span></span>) {
  <span class="hljs-comment">// Use fetchQuery to pre-fill cache and throw errors to errorElement</span>
  <span class="hljs-keyword">await</span> queryClient.<span class="hljs-title function_">fetchQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, params.<span class="hljs-property">id</span>],
    <span class="hljs-attr">queryFn</span>: <span class="hljs-title function_">async</span> () =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`/api/posts/<span class="hljs-subst">${params.id}</span>`</span>);
      <span class="hljs-keyword">if</span> (!response.<span class="hljs-property">ok</span>) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Response</span>(<span class="hljs-string">'Not Found'</span>, { <span class="hljs-attr">status</span>: <span class="hljs-number">404</span> });
      <span class="hljs-keyword">return</span> response.<span class="hljs-title function_">json</span>();
    },
  });

  <span class="hljs-comment">// Return null or empty object - React Query has the data</span>
  <span class="hljs-keyword">return</span> {};
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Post</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> params = <span class="hljs-title function_">useParams</span>();

  <span class="hljs-comment">// React Query cache already has this data from the loader</span>
  <span class="hljs-comment">// Component shows it instantly, no loading state</span>
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: post, isLoading } = <span class="hljs-title function_">useQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, params.<span class="hljs-property">id</span>],
    <span class="hljs-attr">queryFn</span>: <span class="hljs-title function_">async</span> () =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`/api/posts/<span class="hljs-subst">${params.id}</span>`</span>);
      <span class="hljs-keyword">return</span> response.<span class="hljs-title function_">json</span>();
    },
    <span class="hljs-attr">staleTime</span>: <span class="hljs-number">60_000</span>, <span class="hljs-comment">// Consider fresh for 1 minute</span>
    <span class="hljs-attr">refetchOnWindowFocus</span>: <span class="hljs-literal">true</span>, <span class="hljs-comment">// Refetch when user returns</span>
  });

  <span class="hljs-keyword">if</span> (isLoading) <span class="hljs-keyword">return</span> <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>Loading...<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>;

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">article</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span>{post.title}<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>{post.content}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">article</span>&gt;</span></span>
  );
}
</code></pre><p>What happens:</p>
<ol>
<li>User visits <code>/posts/123</code></li>
<li>Loader runs on server, calls <code>fetchQuery</code> which populates React Query cache</li>
<li>Component renders, <code>useQuery</code> finds data already in cache, shows it instantly</li>
<li>No loading state, users see content immediately</li>
<li>React Query continues managing the cache (refetch on focus, invalidation, etc.)</li>
</ol>
<p>This is better than using <code>initialData</code> because:</p>
<ul>
<li>The cache is populated before rendering (no hydration issues)</li>
<li>Error handling works correctly (errors throw to errorElement)</li>
<li>Multiple components can read from the same cache entry</li>
<li>Refetching logic works consistently</li>
</ul>
<h3>Mutations with Hybrid Approach</h3>
<p>Mutations work beautifully with this pattern. Use React Query mutations, invalidate the query, and React Query refetches automatically:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/components/like-button.tsx</span>
<span class="hljs-keyword">import</span> { useMutation, useQueryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">LikeButton</span>(<span class="hljs-params">{ postId }: { postId: <span class="hljs-built_in">string</span> }</span>) {
  <span class="hljs-keyword">const</span> queryClient = <span class="hljs-title function_">useQueryClient</span>();

  <span class="hljs-keyword">const</span> likeMutation = <span class="hljs-title function_">useMutation</span>({
    <span class="hljs-attr">mutationFn</span>: <span class="hljs-title function_">async</span> () =&gt; {
      <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`/api/posts/<span class="hljs-subst">${postId}</span>/like`</span>, { <span class="hljs-attr">method</span>: <span class="hljs-string">'POST'</span> });
    },
    <span class="hljs-attr">onSuccess</span>: <span class="hljs-function">() =&gt;</span> {
      <span class="hljs-comment">// Invalidate the post query - React Query refetches automatically</span>
      queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, postId] });
    },
  });

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{()</span> =&gt;</span> likeMutation.mutate()}&gt;
      Like
    <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span></span>
  );
}
</code></pre><p>The invalidation triggers a refetch. If you navigate away and come back, the loader pre-fills the cache with fresh data. Everything stays synchronized.</p>
<h3>Pre-Filling Multiple Queries</h3>
<p>Loaders can pre-fill multiple related queries:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/posts.$id.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ params }: <span class="hljs-title class_">LoaderFunctionArgs</span></span>) {
  <span class="hljs-comment">// Pre-fill multiple queries in parallel</span>
  <span class="hljs-keyword">await</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">all</span>([
    queryClient.<span class="hljs-title function_">fetchQuery</span>({
      <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, params.<span class="hljs-property">id</span>],
      <span class="hljs-attr">queryFn</span>: <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">fetchPost</span>(params.<span class="hljs-property">id</span>),
    }),
    queryClient.<span class="hljs-title function_">fetchQuery</span>({
      <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, params.<span class="hljs-property">id</span>, <span class="hljs-string">'comments'</span>],
      <span class="hljs-attr">queryFn</span>: <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">fetchComments</span>(params.<span class="hljs-property">id</span>),
    }),
    queryClient.<span class="hljs-title function_">fetchQuery</span>({
      <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, params.<span class="hljs-property">id</span>, <span class="hljs-string">'author'</span>],
      <span class="hljs-attr">queryFn</span>: <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">fetchAuthor</span>(params.<span class="hljs-property">id</span>),
    }),
  ]);

  <span class="hljs-keyword">return</span> {};
}

<span class="hljs-comment">// Components use the queries independently</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Post</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> params = <span class="hljs-title function_">useParams</span>();

  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: post } = <span class="hljs-title function_">useQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, params.<span class="hljs-property">id</span>],
    <span class="hljs-attr">queryFn</span>: <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">fetchPost</span>(params.<span class="hljs-property">id</span>!),
  });

  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: comments } = <span class="hljs-title function_">useQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>, params.<span class="hljs-property">id</span>, <span class="hljs-string">'comments'</span>],
    <span class="hljs-attr">queryFn</span>: <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">fetchComments</span>(params.<span class="hljs-property">id</span>!),
  });

  <span class="hljs-comment">// All data is already cached from the loader</span>
  <span class="hljs-comment">// No loading states, instant render</span>
  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">article</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span>{post.title}<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">Comments</span> <span class="hljs-attr">comments</span>=<span class="hljs-string">{comments}</span> /&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">article</span>&gt;</span></span>
  );
}
</code></pre><h3>Prefetching on Hover</h3>
<p>Combine React Router's <code>&lt;Link&gt;</code> with React Query prefetching for instant navigation:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/components/post-link.tsx</span>
<span class="hljs-keyword">import</span> { <span class="hljs-title class_">Link</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router'</span>;
<span class="hljs-keyword">import</span> { useQueryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">PostLink</span>(<span class="hljs-params">{ post }: { post: Post }</span>) {
  <span class="hljs-keyword">const</span> queryClient = <span class="hljs-title function_">useQueryClient</span>();

  <span class="hljs-keyword">return</span> (
    <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">Link</span>
      <span class="hljs-attr">to</span>=<span class="hljs-string">{</span>`/<span class="hljs-attr">posts</span>/${<span class="hljs-attr">post.id</span>}`}
      <span class="hljs-attr">onMouseEnter</span>=<span class="hljs-string">{()</span> =&gt;</span> {
        // Prefetch on hover
        queryClient.prefetchQuery({
          queryKey: ['posts', post.id],
          queryFn: () =&gt; fetchPost(post.id),
        });
      }}
    &gt;
      {post.title}
    <span class="hljs-tag">&lt;/<span class="hljs-name">Link</span>&gt;</span></span>
  );
}
</code></pre><p>Hover over a link, React Query prefetches the data. Click the link, the loader finds it already cached. The page loads instantly.</p>
<h3>When to Use This Pattern</h3>
<p>Use loaders + React Query together when you need:</p>
<ul>
<li>Fast initial page loads (server-rendered HTML with data)</li>
<li>Rich client-side interactions (optimistic updates, polling, invalidation)</li>
<li>Shared data across components (global cache)</li>
<li>Progressive enhancement (works without JavaScript, enhanced with it)</li>
</ul>
<p>I use this pattern for production apps that need both excellent perceived performance and sophisticated data management. The extra setup is worth it for complex applications.</p>
<h2>Performance and Bundle Size Trade-offs</h2>
<p>Choosing between loaders only, React Query only, or both has real performance implications. Here's what you're trading:</p>
<h3>Bundle Size</h3>
<p><strong>React Router loaders: ~0KB additional</strong></p>
<ul>
<li>Built into React Router, no extra dependency</li>
<li>No client-side data fetching library needed</li>
<li>Minimal JavaScript for the data layer</li>
</ul>
<p><strong>React Query: ~45KB minified + gzipped</strong></p>
<ul>
<li>Core library: ~40KB</li>
<li>DevTools (dev only): ~5KB</li>
<li>This is meaningful on slow connections or low-powered devices</li>
</ul>
<p>If you're building a documentation site, blog, or marketing pages where most users visit 1-2 pages and leave, those 45KB matter. You're paying for features you don't use.</p>
<p>If you're building a SaaS dashboard where users spend hours per session with hundreds of interactions, those 45KB are negligible compared to the value React Query provides.</p>
<h3>Time to Interactive (TTI)</h3>
<p><strong>Loaders excel at TTI:</strong></p>
<pre><code class="hljs">User requests page
  → Server runs loader
  → Server renders HTML with data
  → Browser receives complete HTML
  → Page is interactive immediately
</code></pre><p>The HTML contains the data. No client-side JavaScript needs to run to fetch and display content. This is perfect for content sites, blogs, and server-first applications.</p>
<p><strong>React Query adds hydration overhead:</strong></p>
<pre><code class="hljs">User requests page
  → Server runs loader (if using hybrid approach)
  → Server renders HTML
  → Browser receives HTML
  → React hydrates
  → React Query hydrates its cache
  → Page is fully interactive
</code></pre><p>The extra hydration step adds 50-200ms depending on cache size. Not huge, but measurable. If you're optimizing for the fastest possible TTI, loaders alone win.</p>
<h3>Ongoing Performance</h3>
<p><strong>After initial load, React Query wins:</strong></p>
<p>With loaders:</p>
<ul>
<li>Navigate back: refetch everything (network request, wait, render)</li>
<li>Switch tabs and return: refetch everything</li>
<li>Same data in multiple places: multiple fetches</li>
</ul>
<p>With React Query:</p>
<ul>
<li>Navigate back: instant (cached data shows immediately)</li>
<li>Switch tabs and return: instant (cached data) + background refetch</li>
<li>Same data in multiple places: one fetch, shared cache</li>
</ul>
<p>I measured a CMS built with loaders only vs the same app with React Query. Over a 5-minute user session with typical navigation:</p>
<ul>
<li><strong>Loaders only</strong>: 47 network requests, 850KB transferred, users waited for loading states 8 times</li>
<li><strong>React Query</strong>: 12 network requests, 240KB transferred, users saw loading states once (initial load)</li>
</ul>
<p>The caching eliminated 75% of network requests and made navigation feel instant. The 45KB bundle cost was paid back in reduced data transfer and better UX.</p>
<h3>When Bundle Size Matters Most</h3>
<p>Add React Query when:</p>
<ul>
<li>Users stay on your app for extended sessions (dashboards, tools, SaaS)</li>
<li>Lots of navigation between routes</li>
<li>Data appears in multiple places</li>
<li>User interactions trigger updates frequently</li>
</ul>
<p>Skip React Query when:</p>
<ul>
<li>Users visit one page and leave (landing pages, blogs, docs)</li>
<li>Each page is independent with no shared data</li>
<li>Server rendering is the priority</li>
<li>You're optimizing for the smallest possible bundle</li>
</ul>
<h3>Real-World Bundle Analysis</h3>
<p>For a typical React Router v7 app, here's what you're shipping:</p>
<p><strong>Minimal (loaders only):</strong></p>
<ul>
<li>React + React DOM: ~140KB</li>
<li>React Router: ~30KB</li>
<li>Your app code: varies</li>
<li><strong>Total base: ~170KB</strong></li>
</ul>
<p><strong>With React Query:</strong></p>
<ul>
<li>React + React DOM: ~140KB</li>
<li>React Router: ~30KB</li>
<li>React Query: ~45KB</li>
<li>Your app code: varies</li>
<li><strong>Total base: ~215KB</strong></li>
</ul>
<p>That's a 26% increase in baseline JavaScript. For some apps it's worth it. For others it's not. Make the choice deliberately based on your data patterns, not defaults.</p>
<div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"></path></svg>Note</p>
<p>These sizes are minified + gzipped. React Query v5 reduced bundle size compared to v4, but it is still a meaningful addition for performance-sensitive apps.</p>
</div>
<h2>The Practical Decision Tree</h2>
<p>Ask these questions about your app:</p>
<h3>Start with React Router Loaders Alone</h3>
<p>✅ <strong>Use loaders only if:</strong></p>
<ul>
<li><strong>Each route owns its data</strong>: Post detail page needs post data. Settings page needs settings. No overlap.</li>
<li><strong>Users navigate linearly</strong>: Landing page → feature page → signup. Not much back-button usage.</li>
<li><strong>Session duration &lt; 5 minutes</strong>: Landing pages, docs, marketing sites. Users read and leave.</li>
<li><strong>Mutations are simple</strong>: Create post → redirect to post. Update settings → show success. Automatic revalidation handles it.</li>
<li><strong>Bundle size matters</strong>: You're shipping to users on slow connections or tracking performance budgets.</li>
</ul>
<p><strong>Examples:</strong> Documentation sites, blogs, marketing websites, simple CRUD admin panels, server-rendered content sites.</p>
<h3>Add React Query When You Hit These Signals</h3>
<p>⚠️ <strong>Add React Query if you notice:</strong></p>
<ul>
<li><strong>Network tab shows duplicates</strong>: Same API call fetching the same data multiple times in one session.</li>
<li><strong>Multiple loaders fetch the same thing</strong>: User profile appears in 3+ route loaders. You're querying the database repeatedly for identical data.</li>
<li><strong>Users navigate back frequently</strong>: Analytics show high back-button usage. Users seeing loading states they already saw.</li>
<li><strong>Data needs to stay fresh</strong>: Stock prices, order status, live metrics. You need polling or refetch intervals.</li>
<li><strong>Mutations affect multiple places</strong>: Create a post and need to update the list page, profile page, and home feed.</li>
<li><strong>Optimistic updates would help</strong>: Like buttons, toggles, quick edits feel slow waiting for server confirmation.</li>
</ul>
<p><strong>Examples:</strong> SaaS dashboards, social media apps, real-time monitoring tools, collaborative editors, e-commerce apps with cart management.</p>
<h3>Use the Hybrid Approach (Both Together)</h3>
<p>🚀 <strong>Use loaders + React Query when:</strong></p>
<ul>
<li><strong>SSR is critical AND you need rich interactions</strong>: You need fast initial page loads (SEO, perceived performance) but also sophisticated client-side data management.</li>
<li><strong>Progressive enhancement matters</strong>: App works without JavaScript (loaders render content) but enhanced with it (React Query adds caching, optimistic updates).</li>
<li><strong>Mix of static and dynamic routes</strong>: Some routes are simple (use loaders only), others need real-time updates (add React Query where needed).</li>
<li><strong>Building a complex app incrementally</strong>: Start with loaders, add React Query to specific routes as needed. Best of both worlds.</li>
</ul>
<p><strong>Examples:</strong> E-commerce sites (product pages are static, cart is dynamic), content platforms with personalization, admin dashboards with mixed data patterns, complex multi-page forms with shared state.</p>
<h3>Quick Reference by App Type</h3>
<table>
<thead>
<tr>
<th>App Type</th>
<th>Recommendation</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Blog, docs, marketing</td>
<td>Loaders only</td>
<td>Simple, fast, small bundle</td>
</tr>
<tr>
<td>Simple CRUD admin</td>
<td>Loaders only</td>
<td>Automatic revalidation handles most cases</td>
</tr>
<tr>
<td>Real-time dashboard</td>
<td>React Query only</td>
<td>Need polling, shared data, real-time updates</td>
</tr>
<tr>
<td>SaaS with many routes</td>
<td>Both (hybrid)</td>
<td>Mix of static and dynamic data patterns</td>
</tr>
<tr>
<td>E-commerce storefront</td>
<td>Both (hybrid)</td>
<td>Product pages (loaders) + cart (React Query)</td>
</tr>
<tr>
<td>Social media app</td>
<td>React Query + loaders</td>
<td>Heavy caching needs, shared data, optimistic updates</td>
</tr>
<tr>
<td>Collaborative tool</td>
<td>React Query + loaders</td>
<td>Real-time updates, shared state, complex invalidation</td>
</tr>
</tbody></table>
<p>The decision isn't about which is "better." It's about matching the tool to your data patterns and user behavior.</p>
<h2>Migration Path: Adding React Query When You Need It</h2>
<p>You started with loaders only. Now you're hitting limitations. Here's how to add React Query incrementally without rewriting your entire app.</p>
<h3>Step 1: Install and Configure</h3>
<pre><code class="hljs language-bash">bun add @tanstack/react-query @tanstack/react-query-devtools
</code></pre><p>Add the provider to your root route (shown in "Setting Up React Query" section above). Your existing loader-based routes keep working unchanged.</p>
<h3>Step 2: Identify Problem Areas First</h3>
<p>Don't migrate everything. Find the routes where loaders are causing pain:</p>
<ul>
<li>Data fetched repeatedly (check network tab for duplicates)</li>
<li>Shared data fetched in multiple loaders</li>
<li>Routes that need real-time updates or polling</li>
<li>Complex invalidation needs (mutations affecting multiple routes)</li>
</ul>
<p>Start with these routes. Leave routes that work fine alone.</p>
<h3>Step 3: Migrate One Route to Hybrid Pattern</h3>
<p>Pick one problematic route. Convert it to use the hybrid pattern:</p>
<p><strong>Before (loader only):</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/dashboard.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> metrics = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchMetrics</span>();
  <span class="hljs-keyword">return</span> { metrics };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Dashboard</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> { metrics } = useLoaderData&lt;<span class="hljs-keyword">typeof</span> loader&gt;();
  <span class="hljs-keyword">return</span> <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>Active Users: {metrics.activeUsers}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>;
}
</code></pre><p><strong>After (hybrid with React Query):</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/dashboard.tsx</span>
<span class="hljs-keyword">import</span> { useQuery } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;
<span class="hljs-keyword">import</span> { queryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'~/query-client'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params"></span>) {
  <span class="hljs-comment">// Pre-fill cache</span>
  <span class="hljs-keyword">await</span> queryClient.<span class="hljs-title function_">fetchQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'metrics'</span>],
    <span class="hljs-attr">queryFn</span>: fetchMetrics,
  });
  <span class="hljs-keyword">return</span> {};
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Dashboard</span>(<span class="hljs-params"></span>) {
  <span class="hljs-comment">// Use React Query in component</span>
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: metrics } = <span class="hljs-title function_">useQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'metrics'</span>],
    <span class="hljs-attr">queryFn</span>: fetchMetrics,
    <span class="hljs-attr">refetchInterval</span>: <span class="hljs-number">30_000</span>, <span class="hljs-comment">// Now we can poll!</span>
  });

  <span class="hljs-keyword">return</span> <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>Active Users: {metrics?.activeUsers}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>;
}
</code></pre><p>Deploy this one change. Verify it works. Now that route gets all React Query benefits (caching, polling, invalidation) while keeping the fast initial load from the loader.</p>
<h3>Step 4: Extract Shared Data to React Query</h3>
<p>If user data appears in multiple route loaders, extract it:</p>
<p><strong>Before:</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/routes/posts._index.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ request }</span>) {
  <span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getUser</span>(request); <span class="hljs-comment">// Fetched here</span>
  <span class="hljs-keyword">const</span> posts = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getPosts</span>();
  <span class="hljs-keyword">return</span> { user, posts };
}

<span class="hljs-comment">// app/routes/settings.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ request }</span>) {
  <span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getUser</span>(request); <span class="hljs-comment">// And here</span>
  <span class="hljs-keyword">const</span> settings = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getSettings</span>();
  <span class="hljs-keyword">return</span> { user, settings };
}
</code></pre><p><strong>After:</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/hooks/use-current-user.ts</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">useCurrentUser</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">return</span> <span class="hljs-title function_">useQuery</span>({
    <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'currentUser'</span>],
    <span class="hljs-attr">queryFn</span>: <span class="hljs-title function_">async</span> () =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">'/api/me'</span>);
      <span class="hljs-keyword">return</span> response.<span class="hljs-title function_">json</span>();
    },
    <span class="hljs-attr">staleTime</span>: <span class="hljs-number">1000</span> * <span class="hljs-number">60</span> * <span class="hljs-number">5</span>, <span class="hljs-comment">// Cache for 5 minutes</span>
  });
}

<span class="hljs-comment">// app/routes/posts._index.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> posts = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getPosts</span>(); <span class="hljs-comment">// Only fetch posts</span>
  <span class="hljs-keyword">return</span> { posts };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">PostsIndex</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: user } = <span class="hljs-title function_">useCurrentUser</span>(); <span class="hljs-comment">// Get user from cache</span>
  <span class="hljs-keyword">const</span> { posts } = useLoaderData&lt;<span class="hljs-keyword">typeof</span> loader&gt;();

  <span class="hljs-keyword">return</span> <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>Welcome {user?.name}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>;
}
</code></pre><p>Now user data fetches once, caches, and every component can access it. This is the biggest win when migrating.</p>
<h3>Step 5: Convert Mutations to React Query</h3>
<p>Replace action-based mutations with React Query mutations for better control:</p>
<p><strong>Before:</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">action</span>(<span class="hljs-params">{ request, params }</span>) {
  <span class="hljs-keyword">const</span> formData = <span class="hljs-keyword">await</span> request.<span class="hljs-title function_">formData</span>();
  <span class="hljs-keyword">await</span> <span class="hljs-title function_">updatePost</span>(params.<span class="hljs-property">id</span>, formData);
  <span class="hljs-keyword">return</span> <span class="hljs-title function_">redirect</span>(<span class="hljs-string">`/posts/<span class="hljs-subst">${params.id}</span>`</span>);
}
</code></pre><p><strong>After:</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">import</span> { useMutation, useQueryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">EditPost</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> queryClient = <span class="hljs-title function_">useQueryClient</span>();

  <span class="hljs-keyword">const</span> updateMutation = <span class="hljs-title function_">useMutation</span>({
    <span class="hljs-attr">mutationFn</span>: <span class="hljs-function">(<span class="hljs-params">data</span>) =&gt;</span> <span class="hljs-title function_">updatePost</span>(postId, data),
    <span class="hljs-attr">onSuccess</span>: <span class="hljs-function">() =&gt;</span> {
      <span class="hljs-comment">// Invalidate all affected queries</span>
      queryClient.<span class="hljs-title function_">invalidateQueries</span>({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">'posts'</span>] });
      <span class="hljs-title function_">navigate</span>(<span class="hljs-string">`/posts/<span class="hljs-subst">${postId}</span>`</span>);
    },
  });

  <span class="hljs-keyword">return</span> <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">form</span> <span class="hljs-attr">onSubmit</span>=<span class="hljs-string">{(e)</span> =&gt;</span> {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    updateMutation.mutate(Object.fromEntries(formData));
  }} /&gt;</span>;
}
</code></pre><p>Now you can do optimistic updates, show loading states, and invalidate queries across the entire app.</p>
<h3>Step 6: Keep What Works</h3>
<p>Not every route needs React Query. I have apps where:</p>
<ul>
<li>80% of routes use loaders only (simple CRUD, settings pages, forms)</li>
<li>20% of routes use React Query (dashboards, shared data, real-time features)</li>
</ul>
<p>This is fine. The hybrid approach lets you use the right tool for each route. Static content? Loader. Dynamic, shared, or real-time? React Query.</p>
<h3>Common Migration Pitfalls</h3>
<p><strong>Don't immediately remove all loaders:</strong></p>
<p>Loaders are still valuable for SSR and initial data. Keep them to pre-fill React Query cache.</p>
<p><strong>Don't duplicate query logic:</strong></p>
<p>Create shared query functions:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// app/queries/posts.ts</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> postQueries = {
  <span class="hljs-attr">all</span>: <span class="hljs-function">() =&gt;</span> [<span class="hljs-string">'posts'</span>] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>,
  <span class="hljs-attr">lists</span>: <span class="hljs-function">() =&gt;</span> [...postQueries.<span class="hljs-title function_">all</span>(), <span class="hljs-string">'list'</span>] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>,
  <span class="hljs-attr">list</span>: <span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">filters</span>: <span class="hljs-built_in">string</span></span>) =&gt;</span> [...postQueries.<span class="hljs-title function_">lists</span>(), { filters }] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>,
  <span class="hljs-attr">details</span>: <span class="hljs-function">() =&gt;</span> [...postQueries.<span class="hljs-title function_">all</span>(), <span class="hljs-string">'detail'</span>] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>,
  <span class="hljs-attr">detail</span>: <span class="hljs-function">(<span class="hljs-params"><span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span></span>) =&gt;</span> [...postQueries.<span class="hljs-title function_">details</span>(), id] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>,
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">fetchPost</span>(<span class="hljs-params"><span class="hljs-attr">id</span>: <span class="hljs-built_in">string</span></span>) {
  <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-string">`/api/posts/<span class="hljs-subst">${id}</span>`</span>);
  <span class="hljs-keyword">return</span> response.<span class="hljs-title function_">json</span>();
}

<span class="hljs-comment">// Use in both loaders and components</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ params }</span>) {
  <span class="hljs-keyword">await</span> queryClient.<span class="hljs-title function_">fetchQuery</span>({
    <span class="hljs-attr">queryKey</span>: postQueries.<span class="hljs-title function_">detail</span>(params.<span class="hljs-property">id</span>),
    <span class="hljs-attr">queryFn</span>: <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">fetchPost</span>(params.<span class="hljs-property">id</span>),
  });
  <span class="hljs-keyword">return</span> {};
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Post</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> params = <span class="hljs-title function_">useParams</span>();
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">data</span>: post } = <span class="hljs-title function_">useQuery</span>({
    <span class="hljs-attr">queryKey</span>: postQueries.<span class="hljs-title function_">detail</span>(params.<span class="hljs-property">id</span>!),
    <span class="hljs-attr">queryFn</span>: <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">fetchPost</span>(params.<span class="hljs-property">id</span>!),
  });
}
</code></pre><p>This keeps query keys consistent and logic DRY.</p>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>Use a query key factory (like <code>postQueries</code> above) from day one. Inconsistent query keys cause silent cache misses that are painful to debug.</p>
</div>
<p><strong>Don't over-cache:</strong></p>
<p>Set appropriate <code>staleTime</code> and <code>cacheTime</code>. Don't cache everything forever. Fresh data matters more than cache hits for critical information.</p>
<p>The migration doesn't have to happen all at once. Add React Query where it solves problems. Keep loaders where they work well. Ship incrementally.</p>
<h2>What I'd Do Differently</h2>
<p>I used to add React Query by default to every project. After working with React Router v7's framework mode for six months, I don't. The automatic revalidation after actions covers 80% of what I needed React Query for.</p>
<p>For new projects, I start with just loaders. When I find myself needing data in multiple places or wanting background updates, I add React Query. This keeps the initial bundle smaller and the mental model simpler.</p>
<p>The combination of server-side loaders for initial data and React Query for client-side updates is powerful when you need it. But don't reach for it until you actually need it.</p>
<p>I have a production app with 60+ routes using just loaders. It's a content management system where each route loads its own data. Fast, simple, maintainable. React Query would add complexity without benefit.</p>
<p>I have another production app that's a real-time dashboard. React Query is critical there. Shared data across widgets, background polling, optimistic updates when users interact. Loaders alone wouldn't work.</p>
<p>The right choice depends on your data patterns, not dogma about which tool is "better."</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Origin Financial vs Copilot Money: The Data Connector Problem]]></title>
      <link>https://kahwee.com/2025/origin-financial-vs-copilot-money-comparison/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/origin-financial-vs-copilot-money-comparison/</guid>
      <pubDate>Mon, 10 Nov 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Origin Financial promises comprehensive financial management with AI, but how does it compare to Copilot Money's simplicity? The real difference isn't in features—it's in data connectors. Plaid vs MX vs Finicity matters more than you think.]]></description>
      <category>finance</category>
      <category>technology</category>
      <category>personal</category>
      <category>ai</category>
      <content:encoded><![CDATA[<p>I use Copilot Money. I tried Origin Financial as an alternative. The feature gap isn't the problem. The execution gap is.</p>
<h2>The Automation Problem</h2>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Origin requires constant manual transaction review, which defeats the purpose of financial automation.</p>
</div>
<p>Copilot categorizes transactions automatically, and I only need to check in once a week. Origin wants me to review transactions regularly, shifting work from manual entry to manual review. That is busy work dressed up as a feature.</p>
<h2>Missing the Basics</h2>
<p>Origin tries to replace Mint with net worth tracking, AI planning, tax filing, estate planning, and CFP access, which is an ambitious scope.</p>
<p>The AI features are slow, and they generate insights you will probably ignore. Copilot's simpler approach already covers the same ground faster.</p>
<p>Origin can't handle bulk delete for transactions. When you import accounts or fix mistakes, you delete one-by-one. You can't ship tax filing before you've nailed bulk delete.</p>
<p>Both apps use Plaid for bank connections. Origin adds MX and Finicity as fallbacks — 50,000+ connections vs Plaid's 11,000. That matters if your bank doesn't connect through Plaid. But better connectivity is worthless if the core experience exhausts you.</p>
<h2>What Copilot Got Right</h2>
<p>Copilot iterated on the core experience. The app doesn't ask you to do work it should handle.</p>
<p>Origin feels unfinished, with an ambitious roadmap but lagging execution.</p>
<p>If I were building a financial app: start with Plaid, make automation work without user validation, add bulk operations, then expand. Don't ask users to do your job.</p>
<p>Copilot gets this right, while Origin does not — at least not yet.</p>
<p>Origin's ambition is worth exploring if you need multiple data connectors, tax filing, or estate planning. Use this <a href="https://www.useorigin.com/referral?referral_code=db13ac7c-796e-4ece-b366-9212d5734cd2" target="_blank" rel="noopener noreferrer nofollow">referral link</a> to get your first year for $1. Go in knowing you'll review transactions regularly. That's the trade-off.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[React Compiler 1.0 - You Can Delete Your useCallback Hooks Now]]></title>
      <link>https://kahwee.com/2025/react-compiler-automatic-memoization/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/react-compiler-automatic-memoization/</guid>
      <pubDate>Sun, 09 Nov 2025 17:00:00 GMT</pubDate>
      <description><![CDATA[React Compiler 1.0 ships automatic memoization. Delete your React.memo, useCallback, and useMemo calls—the compiler does it better. Next.js 16 makes Turbopack default with 2-5x faster builds.]]></description>
      <category>web-development</category>
      <category>technology</category>
      <category>build-tool</category>
      <category>performance</category>
      <category>react</category>
      <content:encoded><![CDATA[<p>React Compiler 1.0 shipped in October 2025. Delete most of your React.memo, useCallback, and useMemo calls. The compiler handles memoization automatically — and more granularly than you were doing by hand.</p>
<p>Next.js 16 (also October) made Turbopack the default bundler with 2-5x faster builds. Bun 1.3 shipped full-stack runtime support. The entire React ecosystem got a performance upgrade without requiring you to rewrite anything.</p>
<h2>Before and After</h2>
<p>Before: you wrapped components in React.memo, dependencies in useCallback, and expensive calculations in useMemo. You forgot half the time. When you remembered, you maintained dependency arrays by hand.</p>
<p>After: the compiler analyzes your code and memoizes values used in rendering. It memoizes conditionally — something you could not do manually.</p>
<p>This code:</p>
<pre><code class="hljs language-jsx"><span class="hljs-keyword">function</span> <span class="hljs-title function_">Form</span>(<span class="hljs-params">{ onSubmit, onMount }</span>) {
  <span class="hljs-comment">// your component logic</span>
}
</code></pre><p>Now behaves as if you wrapped <code>onSubmit</code> and <code>onMount</code> in useCallback and <code>Form</code> in React.memo. You did not have to write any of that manually because the compiler handles it during the build.</p>
<p>You write cleaner code. The compiler catches edge cases you would miss. Same philosophy as TypeScript — let the tooling do the work.</p>
<h2>When You Still Need Manual Memoization</h2>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>One place: useEffect dependencies. If you pass a value to useEffect and want to control when it runs, wrap it in useMemo.</p>
</div>
<p>The <a href="https://react.dev/blog/2025/10/07/react-compiler-1" target="_blank" rel="noopener noreferrer nofollow">official React blog</a> explains this: manual memoization guarantees reference stability for effect dependencies. The effect only runs when the dependency changes in value, not on every render.</p>
<h2>Setting It Up</h2>
<p>React Compiler requires React 19+. For React 17 or 18, you also need <code>react-compiler-runtime@latest</code>.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>The compiler assumes your components follow the Rules of React (no side effects during render, stable hook ordering). Code that breaks these rules will produce incorrect optimizations silently.</p>
</div>
<h3>With Next.js</h3>
<p>Next.js 16 has the compiler integrated. Update your config:</p>
<pre><code class="hljs language-javascript"><span class="hljs-comment">// next.config.js</span>
<span class="hljs-keyword">const</span> nextConfig = {
  <span class="hljs-attr">experimental</span>: {
    <span class="hljs-attr">reactCompiler</span>: <span class="hljs-literal">true</span>,
  },
};
</code></pre><p>Next.js handles the Babel plugin setup automatically.</p>
<h3>With Vite</h3>
<p>Vite uses esbuild by default, so you add the Babel plugin manually. The trade-off: automatic memoization at the cost of Babel overhead in your build.</p>
<p>Install:</p>
<pre><code class="hljs language-bash">npm install babel-plugin-react-compiler@latest
</code></pre><p>Configure:</p>
<pre><code class="hljs language-javascript"><span class="hljs-comment">// vite.config.js</span>
<span class="hljs-keyword">import</span> { defineConfig } <span class="hljs-keyword">from</span> <span class="hljs-string">"vite"</span>;
<span class="hljs-keyword">import</span> react <span class="hljs-keyword">from</span> <span class="hljs-string">"@vitejs/plugin-react"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title function_">defineConfig</span>({
  <span class="hljs-attr">plugins</span>: [
    <span class="hljs-title function_">react</span>({
      <span class="hljs-attr">babel</span>: {
        <span class="hljs-attr">plugins</span>: [<span class="hljs-string">"babel-plugin-react-compiler"</span>],
      },
    }),
  ],
});
</code></pre><p>The React Compiler must run first in your Babel plugin pipeline. <a href="https://github.com/vitejs/vite-plugin-react" target="_blank" rel="noopener noreferrer nofollow">@vitejs/plugin-react</a> handles this when configured as shown.</p>
<div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"></path></svg>Note</p>
<p>Pure esbuild builds are fast. Adding the React Compiler introduces Babel, which is slower. For most projects, runtime performance gains outweigh the build time cost. If your codebase is large and build time is critical, benchmark both.</p>
</div>
<p>The Vite team is working with <a href="https://oxc-project.github.io/" target="_blank" rel="noopener noreferrer nofollow">oxc</a> on native compiler support. Once that ships alongside <a href="https://rolldown.rs/" target="_blank" rel="noopener noreferrer nofollow">Rolldown</a>, you get automatic memoization without Babel. The React docs will update with migration info when that happens.</p>
<h2>Turbopack Goes Default</h2>
<p>Next.js 16 shipped Turbopack as the default bundler in October. Vercel runs production sites on it (vercel.com, v0.app, nextjs.org). Their benchmarks show 2-5x faster builds depending on project size.</p>
<p>Fast Refresh is about 10x quicker, which is the difference between grabbing coffee during a rebuild and seeing the change instantly.</p>
<p>Next.js 15.5 (August) had Turbopack in beta. Two months later it became the default.</p>
<p>Bun 1.3 added full-stack runtime support and compiles entire apps into standalone binaries. While not directly related to React Compiler, this is part of the same trend: tools getting faster without changing how you write code.</p>
<h2>What Shifted</h2>
<p>Manual optimization became automatic optimization. Your codebase is cleaner without memo wrappers. The compiler catches optimization opportunities you would miss.</p>
<p>Next.js switching to Turbopack as the default signals this is production-ready. They run their own infrastructure on it.</p>
<h2>What To Do</h2>
<ol>
<li>Update to React 19+ (or 17/18 with react-compiler-runtime)</li>
<li>Install the compiler: <code>npm install babel-plugin-react-compiler@latest</code></li>
<li>Configure it (Next.js: experimental flag, Vite: Babel plugin)</li>
<li>Remove React.memo from components where it is not needed</li>
<li>Remove most useCallback/useMemo (keep for useEffect deps)</li>
<li>Watch your bundle size and build times</li>
</ol>
<p>The <a href="https://react.dev/learn/react-compiler/installation" target="_blank" rel="noopener noreferrer nofollow">React Compiler installation guide</a> has framework-specific setup for Next.js, Remix, Vite, and others. The <a href="https://nextjs.org/blog/next-16" target="_blank" rel="noopener noreferrer nofollow">Next.js 16 announcement</a> covers Turbopack details.</p>
<p>Compiler 1.0 and Turbopack becoming default are not about learning new patterns. The tools got better while you keep writing the same code.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[I Called This in June. October Proved Me Right.]]></title>
      <link>https://kahwee.com/2025/october-layoffs-ai-excuse/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/october-layoffs-ai-excuse/</guid>
      <pubDate>Sun, 09 Nov 2025 17:00:00 GMT</pubDate>
      <description><![CDATA[October 2025: 153,074 job cuts, worst in 22 years. AI cited for 31,039 cuts. But the data shows most companies aren't replacing workers with automation—they're using 'AI' as PR cover for pandemic overcorrection.]]></description>
      <category>ai</category>
      <category>corporate-strategy</category>
      <category>layoffs</category>
      <category>workforce</category>
      <category>technology</category>
      <category>business</category>
      <content:encoded><![CDATA[<p>I wrote in June that <a href="/2025/ai-the-new-backdoor-layoff/">AI was becoming the new backdoor layoff</a>. Companies would use "AI efficiency gains" as cover for not hiring people back. October 2025 proved me right — but not in the way I expected.</p>
<p>153,074 job cuts in October. Triple last year's October. The worst since 2003. AI was cited for 31,039 of those cuts. Tech alone lost 33,281 jobs in a single month, six times September's number.</p>
<p>Most companies citing AI aren't replacing workers with automation. They're using "AI transformation" as PR cover for correcting pandemic overhiring while avoiding the word "layoff."</p>
<h2>The Numbers Don't Add Up</h2>
<p>The <a href="https://www.challengergray.com/blog/october-challenger-report-153074-job-cuts-on-cost-cutting-ai/" target="_blank" rel="noopener noreferrer nofollow">Challenger, Gray &amp; Christmas October report</a> breaks down the stated reasons:</p>
<table>
<thead>
<tr>
<th>Reason</th>
<th>October Cuts</th>
<th>What It Really Means</th>
</tr>
</thead>
<tbody><tr>
<td>Cost-cutting</td>
<td>50,437</td>
<td>"We overhired in 2021"</td>
</tr>
<tr>
<td>AI/Automation</td>
<td>31,039</td>
<td>"Wall Street likes this better"</td>
</tr>
<tr>
<td>Warehousing sector</td>
<td>47,878</td>
<td>Actual automation (robots)</td>
</tr>
<tr>
<td>Tech sector</td>
<td>33,281</td>
<td>Pandemic correction</td>
</tr>
</tbody></table>
<p>Cost-cutting was the #1 reason. AI was #2. When you look at who's cutting and what they're saying, the story shifts.</p>
<p>Amazon cut 14,000 corporate jobs. CEO Andy Jassy <a href="https://www.geekwire.com/2025/its-culture-amazon-ceo-says-massive-corporate-layoffs-were-about-agility-not-ai-or-cost-cutting/" target="_blank" rel="noopener noreferrer nofollow">told GeekWire</a> the layoffs weren't "financially driven" or "AI-driven" — it's about "culture" and staying "nimble." Six months earlier, the same CEO wrote in a memo that AI would reduce their corporate workforce over time.</p>
<p>Which explanation is it?</p>
<p>Google cut 100+ design roles from their cloud unit while ramping up AI infrastructure spending. Intel cut 15% of its global workforce — about 25,000 people — after overinvesting in chip manufacturing. Meta spent $19.37 billion on AI this year, double last year, while letting people go.</p>
<p>Companies are using "AI transformation" to avoid saying "we hired too many people in 2021 and now we're fixing it."</p>
<h2>The AI Washing Problem</h2>
<p><a href="https://www.cnbc.com/2025/11/04/white-collar-layoffs-ai-cost-cutting-tariffs.html" target="_blank" rel="noopener noreferrer nofollow">79% of US CEOs</a> fear losing their jobs if they don't deliver measurable AI-driven business gains within two years. That creates an incentive to blame AI for layoffs even when the real reason is overcapacity or cost-cutting.</p>
<p>Business professors call it "AI washing." Tell investors you're firing workers because AI can replace them — they cheer. Tell them you overhired during the pandemic boom — they punish your stock.</p>
<p>Wall Street rewards "AI transformation," but Wall Street punishes "we made hiring mistakes."</p>
<p>The warehousing sector tells the real story. Those 47,878 cuts (a 4,700% month-over-month increase) are not AI washing but actual automation, with robots replacing humans in distribution centers. Year-to-date, warehousing has cut 90,418 jobs, up 378% from last year.</p>
<p>That's what automation-driven layoffs look like. Compare that to Amazon's "culture" excuse or Google's "design role optimization."</p>
<h2>What I'm Seeing in San Francisco</h2>
<p>The tech jobs getting cut aren't the ones AI can replace yet. They're middle management, project coordinators, design researchers — roles companies added when headcount growth was the metric everyone cared about. You see it in <a href="https://yorksf.com/2026/mission-district-guide/" target="_blank" rel="noopener noreferrer nofollow">SF's tech corridors</a>. There are fewer people at the coffee shops during work hours.</p>
<p>The AI jobs everyone promised would replace them? Still mostly infrastructure spending. More GPUs, more AI researchers, more "alignment engineers." Not the distributed workforce of AI-augmented individual contributors we were told would emerge.</p>
<p>Friends who got cut aren't hearing "your role is automated now." They're hearing "we're restructuring" or "eliminating redundancy" or my favorite: "evolving our organizational structure."</p>
<p>One person told me their entire team got eliminated three months after leadership presented a roadmap showing AI would "10x their productivity." The AI tools never materialized. The headcount reduction did.</p>
<h2>The Uncomfortable Truth</h2>
<p>Some of these cuts ARE automation-driven. That warehousing number isn't a typo. Distribution centers are replacing humans with robots at scale. Manufacturing is next. Customer service is already there.</p>
<p>The white-collar tech layoffs are a different story. Most are pandemic correction dressed up as AI strategy. Companies hired aggressively in 2021 based on growth assumptions that proved wildly optimistic. When reality hit, they needed to right-size.</p>
<p>The difference between now and two years ago is the narrative. In 2022-2023, companies announced layoffs and took the stock hit. In 2025, they announce "AI-driven efficiency improvements" and get rewarded.</p>
<p>The outcome is the same, but the optics are better.</p>
<p>Over 1 million job cuts for 2025 so far. Employers announced only 488,077 planned hires through October, down 35% from last year. That's the lowest since 2011.</p>
<p>AI will replace jobs. That's not the question. The question is whether we're honest about what's happening right now. October's data says we're not.</p>
<p>Next time a company cites "AI transformation" for layoffs, check whether they're deploying AI or deploying euphemisms. The Challenger report tracks this monthly. Watch for the gap between "AI cited" and actual automation deployment.</p>
<p>That gap is where the AI washing is happening.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Claude Code Goes Native: Install the Official CLI]]></title>
      <link>https://kahwee.com/2025/claude-code-native-installation/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/claude-code-native-installation/</guid>
      <pubDate>Fri, 31 Oct 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Claude Code now has a native installation option. Here's how to migrate from npm to the official native version.]]></description>
      <category>developer-tool</category>
      <category>artificial-intelligence</category>
      <category>cli</category>
      <category>anthropic</category>
      <content:encoded><![CDATA[<p>Claude Code now ships native installers for macOS, Linux, Windows, and WSL. The npm version was always temporary, so you should switch to the native build.</p>
<h2>Why Switch?</h2>
<p>The native Claude Code binary is faster, more reliable, and maintained as the official distribution. The npm version was a temporary solution while the native version was being developed.</p>
<h2>Uninstall the npm Version</h2>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>If you currently have Claude Code installed via npm, remove it first to avoid conflicts.</p>
</div>
<pre><code class="hljs language-sh">npm uninstall -g @anthropic-ai/claude-code
</code></pre><h2>Install the Native Version</h2>
<p>Pick the method that matches your operating system.</p>
<h3>macOS</h3>
<p>Homebrew:</p>
<pre><code class="hljs language-sh">brew install --cask claude-code
</code></pre><h3>Linux</h3>
<p>Installation script:</p>
<pre><code class="hljs language-sh">curl -fsSL https<span class="hljs-punctuation">:</span><span class="hljs-comment">//claude.ai/install.sh | bash</span>
</code></pre><h3>WSL (Windows Subsystem for Linux)</h3>
<p>Same as Linux:</p>
<pre><code class="hljs language-sh">curl -fsSL https<span class="hljs-punctuation">:</span><span class="hljs-comment">//claude.ai/install.sh | bash</span>
</code></pre><h3>Windows PowerShell</h3>
<p>PowerShell script:</p>
<pre><code class="hljs language-sh">irm https<span class="hljs-punctuation">:</span><span class="hljs-comment">//claude.ai/install.ps1 | iex</span>
</code></pre><div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"></path></svg>Note</p>
<p>On macOS, the Homebrew cask handles updates automatically via <code>brew upgrade</code>. On Linux and WSL, re-run the install script to update.</p>
</div>
<h2>Verify Installation</h2>
<p>Confirm it works:</p>
<pre><code class="hljs language-sh">claude --version
</code></pre><div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>To diagnose any issues, run <code>claude /doctor</code>:</p>
</div>
<pre><code class="hljs language-sh">claude /doctor
</code></pre><p>You should see the version number, which confirms you are running the native CLI.</p>
<h2>What's Next?</h2>
<p>Claude Code handles codebase questions, command execution, and coding tasks from the terminal. See the <a href="https://docs.claude.com/en/docs/claude-code/claude_code_docs_map.md" target="_blank" rel="noopener noreferrer nofollow">documentation</a> for the full feature set.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Meta is Cutting 600 AI Roles to Speed Up Decisions]]></title>
      <link>https://kahwee.com/2025/meta-600-ai-layoffs/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/meta-600-ai-layoffs/</guid>
      <pubDate>Wed, 22 Oct 2025 21:30:00 GMT</pubDate>
      <description><![CDATA[Meta cutting 600 AI roles while hiring for superintelligence. The restructuring signals a shift from research to products—and a clearer vision of what matters.]]></description>
      <category>ai</category>
      <category>meta</category>
      <category>corporate-strategy</category>
      <category>layoffs</category>
      <category>technology</category>
      <category>product-development</category>
      <content:encoded><![CDATA[<p>Meta is cutting 600 AI roles from FAIR (Fundamental AI Research), AI product, and infrastructure teams — while hiring for TBD Lab, its superintelligence division. Alexandr Wang, Meta's Chief AI Officer, explained the cuts plainly: "By reducing the size of our team, fewer conversations will be required to make a decision, and each person will be more load-bearing and have more scope and impact."</p>
<p>The research team had become bureaucratic, and they are cutting the slowness.</p>
<h2>What Wang's Reorganization Tells You</h2>
<p>This isn't cost-cutting disguised as efficiency. It's a priority realignment. Wang (former Scale AI CEO) now runs Meta's AI. Yann LeCun, the legendary FAIR leader, reports to him. Meta chose superintelligence speed over foundational research depth.</p>
<p>The numbers reinforce it. Meta invested $14.3 billion in Scale AI earlier this year and closed a $27 billion financing deal for data centers. Those funds flow to TBD Lab's race for AGI, not to FAIR's long-term research agenda.</p>
<p>FAIR's theoretical breakthroughs eventually become products. But "eventually" doesn't win when you're competing with OpenAI, Google, and Anthropic on superintelligence timelines. When the business question becomes "who gets to AGI first," organizations reshape around speed.</p>
<h2>The Backdoor Layoff — And Why This Isn't One</h2>
<p>This connects to something I've written about before: <a href="/2025/ai-the-new-backdoor-layoff/">AI as the backdoor layoff strategy</a>. Companies can either announce layoffs (stock drops, media scrutiny, morale collapse) or announce AI efficiency gains (stock rises, innovation narrative).</p>
<p>Meta is doing something different. They're being direct about restructuring. They acknowledge the organizational problem. They're not claiming FAIR is failing. They're saying that in a superintelligence race, you optimize differently.</p>
<p>FAIR has produced important research over the years. Research that does not ship is ultimately just cost. If Meta believes AGI is the defining competition, resources should flow there.</p>
<p>Yann LeCun will probably be fine. He's legendary enough to land anywhere, and Meta seems committed to keeping him in some capacity. The message to the broader AI research community is blunt: if your foundational questions won't ship this decade, work somewhere else.</p>
<h2>The Timing</h2>
<p>Meta is signaling it's serious about superintelligence. Not someday, but right now. The company is willing to cut headcount and reorganize around that priority.</p>
<p>Whether TBD Lab delivers anything resembling superintelligence is a separate question. The organizational commitment is real. There is no equivocating about research timelines and no "we support both" messaging. This is what they are betting on.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Introducing ChatGPT Atlas]]></title>
      <link>https://kahwee.com/2025/introducing-chatgpt-atlas/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/introducing-chatgpt-atlas/</guid>
      <pubDate>Tue, 21 Oct 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[OpenAI launched ChatGPT Atlas, an AI-powered browser. Perplexity did the same thing months ago with Comet. So what's the actual story here?]]></description>
      <category>ai-assistant</category>
      <category>browser</category>
      <category>chatgpt</category>
      <category>openai</category>
      <category>perplexity</category>
      <content:encoded><![CDATA[<p>OpenAI announced ChatGPT Atlas — a browser that puts ChatGPT at the center of browsing. Perplexity already did this months ago with Comet. People noticed.</p>
<p></p><div class="video-container"><iframe src="https://www.youtube.com/embed/8UWKxJbjriY" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen loading="lazy"></iframe></div><p></p>
<p>Atlas adds chat, memory, and agent mode to your browser. Talk to ChatGPT about any webpage. It remembers what you've browsed and suggests relevant content. Agents automate bookings and form-filling. Available for macOS now.</p>
<p>Perplexity launched Comet in July 2024 — nearly a year earlier. Same feature set: sidecar assistant, task automation, background agents. After launching at $200/month, they made it free globally in October 2025.</p>
<p>Both solve the same problem the same way, and neither is truly innovative since it is what you would expect when combining an LLM with a browser. OpenAI wins on distribution — 200+ million ChatGPT users. Perplexity gets credit for building it first and making it free.</p>
<h2>What are these browsers actually for?</h2>
<p>I have used Comet to find coupons, search things, and summarize pages, and it works fine. But what can an AI browser do that opening ChatGPT in a tab cannot?</p>
<p>Automated bookings sound appealing, but do you trust your browser with payment info? Managing Linear projects sounds good until you remember people already have integrations, and inline text editing only saves a single click.</p>
<p>People don't need an AI browser. They need Gmail with AI writing, Linear with AI summaries — apps that already have AI built in. The browser is a distribution channel, not the product.</p>
<p>Both assume the bottleneck is information access or task automation. It's judgment. I don't need AI to find the cheapest flight — I need to decide if the layover is worth it. I don't need AI to summarize an article — I need to know if it matters. These browsers automate what was already easy.</p>
<p>OpenAI wins on scale. Perplexity built it first and made it free. Both will find users, but neither will replace Chrome. When you have a great LLM, integrating it everywhere is the obvious next step, and that is not innovation but simply the market filling in the gaps.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Claude Code Comes to the Web and iOS]]></title>
      <link>https://kahwee.com/2025/claude-code-web-and-ios-launch/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/claude-code-web-and-ios-launch/</guid>
      <pubDate>Mon, 20 Oct 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Anthropic launched Claude Code on the web and iOS, expanding beyond CLI. The web version runs multiple tasks in parallel on the cloud. It generates $500 million annually and has driven a 10x increase in users since May.]]></description>
      <category>ai-tool</category>
      <category>claude</category>
      <category>anthropic</category>
      <category>developer-tool</category>
      <category>software-engineering</category>
      <content:encoded><![CDATA[<p>Claude Code started as a CLI tool. Now it runs in the browser, on iOS, in IDE plugins, and through GitHub Actions. The web launch matters most: GitHub OAuth, no setup, coding in your browser.</p>
<p>Claude Code generates $500 million in annual revenue and has seen 10x user growth since May. Inside Anthropic, Claude Code writes 90% of their code, and output per engineer has jumped 67%. When your own tool works that well, you ship it everywhere.</p>
<h2>Three Interfaces, Different Tradeoffs</h2>
<p><strong>Web</strong> runs tasks on Anthropic's cloud. Multiple tasks run in parallel, which the CLI can't do. Real-time progress, mid-task steering, session resumption.</p>
<p><strong>CLI</strong> runs locally with direct filesystem access and is single-threaded. You control the environment, but pay-as-you-go pricing stacks up when you're hammering it.</p>
<p><strong>iOS</strong> is for monitoring and light tasks: checking progress, providing feedback, triggering simple refactors. Anthropic admits it needs refinement.</p>
<p>All three connect to GitHub the same way. Install the GitHub app, authorize access (contents, issues, PRs), add the Claude Dispatch workflow. Claude gets sandboxed with network and filesystem restrictions. A secure proxy handles git operations — it can't touch repos you didn't authorize.</p>
<h2>The Maintenance Machine</h2>
<p>Refactoring, test generation, dependency upgrades, and bug fixing are not glamorous, but that is what fills sprints. React project needs upgrading? Claude analyzes the codebase, identifies breaking changes, runs migrations across files.</p>
<p>Type annotations on JavaScript files. Unit tests for new functions. Database migrations from schema changes. The time savings pile up on repetitive work that eats days, not on architectural decisions.</p>
<p>The web interface shifts the psychology. You're delegating, not augmenting your editor. That changes how you scope the work.</p>
<h2>Distribution Wins</h2>
<p>Claude Code used to live inside Cursor or a VS Code plugin. You had to seek it out.</p>
<p>Now it's a browser tab. GitHub OAuth and you're in. You can try it on one task without committing to another tool.</p>
<p>Pro ($20/month) and Max ($100-200/month) include full Claude Code access across web, iOS, and CLI. Shared limits with regular Claude chat, reset every five hours.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Sora Isn't the Problem: It's the Mirror]]></title>
      <link>https://kahwee.com/2025/sora-isnt-the-problem/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/sora-isnt-the-problem/</guid>
      <pubDate>Sun, 19 Oct 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Sora's TikTok form shows us what social media has always been optimized for. Now that AI makes content frictionless, the machine becomes impossible to ignore.]]></description>
      <category>ai</category>
      <category>ai-tool</category>
      <category>sora</category>
      <category>openai</category>
      <category>social-media</category>
      <category>video-generation</category>
      <category>disinformation</category>
      <content:encoded><![CDATA[<h2>The Real Problem</h2>
<p>I got access to Sora in TikTok form, and something clicked. This isn't annoying because Sora is bad. It's annoying because it's honest.</p>
<h2>Friction Is Gone</h2>
<p>Lying used to have a cost. Fabricating video evidence meant work. Film it. Edit it. Make it convincing. That work was friction. Friction penalized lying with time, effort, and risk of exposure.</p>
<p>Sora removes that penalty entirely.</p>
<p>Now you prompt an AI. Fake historical event? Seconds. Celebrity deepfake? Done. False testimony on video? Trivial. Fabricating is now <em>easier</em> than capturing reality.</p>
<h2>Why This Breaks the System</h2>
<p>Social media optimizes for engagement over truth. That only works if truth is expensive enough to be selective. You can't put <em>everything</em> on the feed.</p>
<p>With friction, lying is <em>selective</em>. You lie when the payoff justifies the work. The system survives because most content is still regular stuff. People sharing their lives. Creators doing real work.</p>
<p>Sora removes that selection. Lying is <em>free</em>. Truth and lies cost the same to produce. Engagement cares about neither. The algorithm picks based on one thing: what keeps you scrolling?</p>
<p>Not truth. Novelty. Spectacle. Uncanniness. Deepfakes of celebrities doing weird things beat someone filming their actual day.</p>
<p>The system <em>must</em> fill the feed with fabrication. Not because creators are evil. The incentives make fabrication <em>cheaper than reality</em>.</p>
<h2>What This Looks Like</h2>
<p>Echo chambers accelerate. TikTok already concentrates algorithmic curation into bubbles. Sora makes those bubbles self-reinforcing: same IP, same memes, same likenesses, generated infinitely. The machine feeding itself.</p>
<p>Truth becomes optional. Audiences stop sorting by "real vs. fake" and start sorting by "entertaining vs. dull." The Verge reported getting trapped in scroll loops of deepfaked celebrities and fabricated moments — even knowing they were AI. The uncanniness is <em>the feature</em>.</p>
<p>You can <em>visually assert</em> anything now. Any scene. Any dialogue. Any context. Short-form video already rewards punchy narratives over grounded reporting. Sora strips away the last constraint. Virality beats truth.</p>
<h2>The Epiphany</h2>
<p>Sora isn't <em>breaking</em> social media. It's making visible what was always there.</p>
<p>We built platforms optimized for engagement. Added algorithms to concentrate attention. Added infinite scroll and habit-forming UI. Added unlimited content generation via AI.</p>
<p>At each step, we told ourselves it was fine. "Engagement metrics are just incentives." "Echo chambers are just efficiency." "Disinformation is a problem we'll solve later."</p>
<p>Watching people trapped in loops of fabricated celebrity cameos ends that pretense. Friction is <em>gone</em>. Lying costs nothing. Engagement doesn't care about truth. The system fills itself with slop.</p>
<p>That's the machine working exactly as designed.</p>
<h2>What Friction Did For Us</h2>
<p>Friction was a natural check. Video production was slow. Editing took time. Deepfakes required skill. There was a <em>price</em> for lying on camera. Most people paid it in truth instead.</p>
<p>Now there is no price. Lying is as effortless as telling the truth. The system was designed assuming <em>some</em> friction. Some cost to fabrication. Some incentive to be selective about deception.</p>
<p>Sora removes that assumption.</p>
<p>Infinite scroll of fabricated content, algorithmically sorted for engagement, presented in a UI designed for habit formation, watched by people who stopped caring if it's real.</p>
<p>The machine didn't break. It just stopped pretending.</p>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2025/ai-the-new-backdoor-layoff/">AI The New Backdoor Layoff</a> - How companies use AI as cover for not hiring</li>
<li><a href="/2025/on-technological-stratification/">On Technological Stratification</a> - How AI widens inequality and access gaps</li>
<li><a href="/2025/perplexity-replaces-google-search-and-apple-news-for-me/">Perplexity replaces Google Search and Apple News for me</a> - How AI is changing information consumption</li>
<li><a href="/2025/google-ai-overviews-cutting-web-traffic-in-half/">Google AI Overviews are cutting web traffic in half</a> - The impact of AI on traditional web patterns</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Recursion Beats Scale]]></title>
      <link>https://kahwee.com/2025/recursion-beats-scale/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/recursion-beats-scale/</guid>
      <pubDate>Fri, 17 Oct 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Samsung's 7M parameter model beats DeepSeek-R1 with recursive thinking. Discover why looping 16x outperforms single-pass solutions in reasoning.]]></description>
      <category>ai</category>
      <category>technology</category>
      <category>large-language-model</category>
      <category>privacy</category>
      <content:encoded><![CDATA[<p>The AI industry won't shut up about scale. Bigger models, more parameters, larger training datasets. Samsung just released a 7-million parameter model that outperforms giants with 10,000x more parameters on abstract reasoning tasks.</p>
<p>The <a href="https://github.com/SamsungSAILMontreal/TinyRecursiveModels" target="_blank" rel="noopener noreferrer nofollow">Tiny Recursive Model (TRM)</a> doesn't solve problems in one pass. It loops: draft an answer, check the logic, rewrite, repeat up to 16 times. An internal "scratchpad" critiques its reasoning, catching mistakes before they compound.</p>
<p>On the <a href="https://arxiv.org/html/2510.04871v1" target="_blank" rel="noopener noreferrer nofollow">ARC-AGI benchmark</a> — abstract reasoning tests that trip up even the best LLMs — TRM scored 45% on ARC-AGI-1 and 8% on ARC-AGI-2. Better than DeepSeek-R1, o3-mini, and Gemini 2.5 Pro, all running billions of parameters. On Sudoku-Extreme with 1,000 training examples, it hit 87.4% accuracy versus its predecessor's 55%.</p>
<p>Alexia Jolicoeur-Martineau, the Samsung researcher behind TRM: "The notion that one must depend on extensive foundational models trained for millions of dollars by major corporations to tackle difficult tasks is misleading."</p>
<p>You don't write down the first answer that pops into your head when solving a hard problem. You draft something, spot the holes, rethink it, try again. TRM does this in a two-layer neural network. Traditional LLMs generate answers in a single forward pass. An early mistake spreads through the entire response.</p>
<p>TRM won't write your emails or debug your code. It handles structured, grid-based reasoning: Sudoku, mazes, abstract pattern recognition. For these problems, a 7-million parameter model running locally beats cloud-based giants — faster, cheaper, and private.</p>
<p>I've been testing Apple's <a href="https://www.apple.com/newsroom/2025/09/apples-foundation-models-framework-unlocks-new-intelligent-app-experiences/" target="_blank" rel="noopener noreferrer nofollow">Foundation Models framework</a>. What I like most is the privacy. It runs entirely offline. Your prompts stay on your device. Apple doesn't use your data to train models.</p>
<p>Cloud-based LLMs log every query you send. Apple's 3-billion parameter model runs natively in iOS 26, iPadOS 26, and macOS 26. It feels like it's mine, not something I'm renting access to.</p>
<p>Samsung's TRM is <a href="https://github.com/SamsungSAILMontreal/TinyRecursiveModels" target="_blank" rel="noopener noreferrer nofollow">open source under an MIT license</a>. Apple's Foundation Models framework gives developers on-device AI with three lines of Swift code. AI doesn't have to phone home. Sometimes the best model runs quietly on your device without sending your data anywhere.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Claude Haiku 4.5: Cheap and Fast]]></title>
      <link>https://kahwee.com/2025/claude-haiku-4-5/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/claude-haiku-4-5/</guid>
      <pubDate>Wed, 15 Oct 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Haiku 4.5 is $0.80 per million input tokens and $4 per million output tokens. It's 3.75x cheaper than Sonnet 4.5, runs in sub-200ms, and handles most development tasks just fine.]]></description>
      <category>ai</category>
      <category>ai-tool</category>
      <category>claude</category>
      <category>anthropic</category>
      <category>large-language-model</category>
      <category>coding</category>
      <category>pricing</category>
      <content:encoded><![CDATA[<h2>The Right Trade</h2>
<p>Anthropic shipped <strong>Claude Haiku 4.5</strong>. You trade deeper reasoning for speed and cost. For most tasks, that's the correct trade.</p>
<h2>The Numbers</h2>
<table>
<thead>
<tr>
<th>Model</th>
<th>Input (1M tokens)</th>
<th>Output (1M tokens)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Haiku 4.5</strong></td>
<td>$0.80</td>
<td>$4</td>
</tr>
<tr>
<td><strong>Sonnet 4.5</strong></td>
<td>$3</td>
<td>$15</td>
</tr>
</tbody></table>
<p>3.75x cheaper. Sub-200ms latency versus Sonnet's 500-800ms. That gap matters for anything that needs to feel responsive.</p>
<p>A small support chatbot costs $5-20/month on Haiku versus $50-150/month on Sonnet. High-volume workloads widen the gap further.</p>
<h2>Where I Use It</h2>
<p>I run Haiku for small refactors, code review, and debugging. It matches Sonnet 4 on most coding tasks. The speed makes it feel snappier, and for real-time pair programming or customer support it is the obvious choice. Sonnet earns its price when you need complex reasoning or nuanced writing.</p>
<h2>January 2025 Cutoff</h2>
<div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"></path></svg>Note</p>
<p>All three recent Claude models (Haiku, Sonnet 4.5, Opus 4.1) have a January 2025 knowledge cutoff. Provide context in your prompt when working with newer frameworks or libraries.</p>
</div>
<p>Established patterns and refactoring work fine, but bleeding-edge tools require extra context in your prompts.</p>
<h2>When Cost Decides</h2>
<p>Haiku makes LLM integration practical for projects where API costs would otherwise kill the idea. You can afford to be generous with calls. It will not impress on reasoning, but it does not need to.</p>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2025/claude-code-strengths-and-weaknesses-mar-2025/">Claude Code's Strengths and Weaknesses in March 2025</a> - See how Haiku compares to other Claude tools</li>
<li><a href="/2025/gpt-4-1-swe-improvements/">GPT-4.1: SWE-bench Performance</a> - Compare Haiku to GPT-4.1 on coding benchmarks</li>
<li><a href="/2025/the-true-cost-of-ai/">The True Cost of AI</a> - Understand AI pricing across different models and services</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[SwiftUI Bottom Toolbar and Placement API]]></title>
      <link>https://kahwee.com/2025/swiftui-declarative-toolbar-placement/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/swiftui-declarative-toolbar-placement/</guid>
      <pubDate>Sun, 12 Oct 2025 17:00:00 GMT</pubDate>
      <description><![CDATA[SwiftUI bottom toolbar guide: Master ToolbarItem placement API for iPhone, iPad, and macOS. Learn .bottomBar positioning, adaptive behavior, and practical workarounds.]]></description>
      <category>swiftui</category>
      <category>ios</category>
      <category>mobile-development</category>
      <category>tutorial</category>
      <content:encoded><![CDATA[<p>SwiftUI's toolbar API does something both impressive and frustrating: <strong>the framework decides what to show and hide based on context you didn't ask it to consider.</strong></p>
<p>You describe placement intent—"put this button top-right"—and SwiftUI interprets based on device, orientation, and available space. Sometimes it honors your intent. Sometimes it moves items to overflow menus. Sometimes it hides them entirely.</p>
<h2>ToolbarItem Basics</h2>
<p>SwiftUI's toolbar API uses a <code>toolbar</code> modifier with <code>ToolbarItem</code> elements. Each needs placement (where you want it) and content (what to show). Apple's <a href="https://developer.apple.com/documentation/swiftui/toolbaritemplacement" target="_blank" rel="noopener noreferrer nofollow">ToolbarItemPlacement documentation</a> lists placements like <code>.topBarLeading</code>, <code>.topBarTrailing</code>, <code>.bottomBar</code>, <code>.status</code>, <code>.title</code>, and <code>.subtitle</code>.</p>
<p>The simplest example:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">import</span> SwiftUI

<span class="hljs-keyword">struct</span> <span class="hljs-title class_">BasicToolbarDemo</span>: <span class="hljs-title class_ inherited__">View</span> {
    <span class="hljs-keyword">var</span> body: <span class="hljs-keyword">some</span> <span class="hljs-type">View</span> {
        <span class="hljs-type">NavigationStack</span> {
            <span class="hljs-type">Text</span>(<span class="hljs-string">"Content goes here"</span>)
                .navigationTitle(<span class="hljs-string">"Basic Toolbar"</span>)
                .toolbar {
                    <span class="hljs-comment">// Single toolbar item in top-right</span>
                    <span class="hljs-type">ToolbarItem</span>(placement: .topBarTrailing) {
                        <span class="hljs-type">Button</span> { } label: {
                            <span class="hljs-type">Image</span>(systemName: <span class="hljs-string">"plus"</span>)
                        }
                    }
                }
        }
    }
}
</code></pre><p><strong>Key points:</strong></p>
<ul>
<li><strong>Must be inside NavigationStack</strong> - Toolbars attach to navigation bars</li>
<li><strong>placement parameter</strong> - Tells SwiftUI where you <em>want</em> it</li>
<li><strong>Content closure</strong> - What to display (usually a Button)</li>
</ul>
<p>Common placements:</p>
<ul>
<li><code>.topBarLeading</code> - Top-left corner (menu, back button)</li>
<li><code>.topBarTrailing</code> - Top-right corner (add, edit, share)</li>
<li><code>.bottomBar</code> - Bottom toolbar area</li>
<li><code>.status</code> - Centered in bottom bar (non-interactive status text)</li>
<li><code>.title</code> - Custom title view in navigation bar</li>
<li><code>.subtitle</code> - Subtitle text below title</li>
</ul>
<h2>The Four Corners Pattern</h2>
<p>I wanted toolbar items in all four screen corners. Common in file browsers (Files app), editing apps (Pages, TextEdit), and photo apps (Photos). Here's the simplest version:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">struct</span> <span class="hljs-title class_">FourCornersDemo</span>: <span class="hljs-title class_ inherited__">View</span> {
    <span class="hljs-keyword">var</span> body: <span class="hljs-keyword">some</span> <span class="hljs-type">View</span> {
        <span class="hljs-type">NavigationStack</span> {
            <span class="hljs-comment">// DemoData.generatePeople() creates 50 placeholder names</span>
            <span class="hljs-type">List</span>(<span class="hljs-type">DemoData</span>.generatePeople(), id: \.<span class="hljs-keyword">self</span>) { person <span class="hljs-keyword">in</span>
                <span class="hljs-type">Text</span>(person)
            }
            .navigationTitle(<span class="hljs-string">"Four Corners"</span>)
            .toolbar {
                <span class="hljs-type">ToolbarItem</span>(placement: .topBarLeading) {
                    <span class="hljs-type">Button</span> { } label: {
                        <span class="hljs-type">Image</span>(systemName: <span class="hljs-string">"line.3.horizontal"</span>)
                    }
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .topBarTrailing) {
                    <span class="hljs-type">Button</span> { } label: {
                        <span class="hljs-type">Image</span>(systemName: <span class="hljs-string">"plus"</span>)
                    }
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .bottomBar) {
                    <span class="hljs-type">Button</span> { } label: {
                        <span class="hljs-type">Label</span>(<span class="hljs-string">"Back"</span>, systemImage: <span class="hljs-string">"chevron.backward"</span>)
                    }
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .bottomBar) {
                    <span class="hljs-type">Spacer</span>()
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .bottomBar) {
                    <span class="hljs-type">Button</span> { } label: {
                        <span class="hljs-type">Label</span>(<span class="hljs-string">"Next"</span>, systemImage: <span class="hljs-string">"chevron.forward"</span>)
                    }
                }
            }
        }
    }
}
</code></pre><p>Five ToolbarItems: top-left, top-right, bottom-left, a Spacer in the middle, and bottom-right. The Spacer pushes the bottom buttons to the edges.</p>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>Always test toolbar layouts on macOS if your app supports it. Bottom bar items disappear entirely on macOS because Mac windows have no bottom toolbar concept.</p>
</div>
<p><video src="https://img.kahwee.com/2025/swiftui-declarative-toolbar-placement/four-corners.mp4" autoplay muted loop controls preload="metadata"></video></p>
<p>On iPhone, all four buttons appear where specified. On iPad, same buttons with more spacing. On macOS, the top buttons appear in the window toolbar, but bottom buttons disappear—macOS windows don't have bottom toolbars, so SwiftUI hides them.</p>
<h3>Kitchen Sink Placements</h3>
<p>SwiftUI has more toolbar placements beyond the four corners. Here's a kitchen sink example showing <code>.title</code>, <code>.subtitle</code>, and <code>.status</code>:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">struct</span> <span class="hljs-title class_">ToolbarKitchenSink</span>: <span class="hljs-title class_ inherited__">View</span> {
    <span class="hljs-meta">@State</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> selectedCount <span class="hljs-operator">=</span> <span class="hljs-number">3</span>
    <span class="hljs-keyword">private</span> <span class="hljs-keyword">let</span> people <span class="hljs-operator">=</span> <span class="hljs-type">DemoData</span>.generatePeople()

    <span class="hljs-keyword">var</span> body: <span class="hljs-keyword">some</span> <span class="hljs-type">View</span> {
        <span class="hljs-type">NavigationStack</span> {
            <span class="hljs-type">List</span>(people, id: \.<span class="hljs-keyword">self</span>) { person <span class="hljs-keyword">in</span>
                <span class="hljs-type">Text</span>(person)
            }
            .navigationTitle(<span class="hljs-string">"Kitchen Sink"</span>)
            .toolbar {
                <span class="hljs-type">ToolbarItem</span>(placement: .topBarLeading) {
                    <span class="hljs-type">Button</span> { } label: {
                        <span class="hljs-type">Image</span>(systemName: <span class="hljs-string">"line.3.horizontal"</span>)
                    }
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .topBarTrailing) {
                    <span class="hljs-type">Button</span> { } label: {
                        <span class="hljs-type">Image</span>(systemName: <span class="hljs-string">"plus"</span>)
                    }
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .title) {
                    <span class="hljs-type">HStack</span>(spacing: <span class="hljs-number">6</span>) {
                        <span class="hljs-type">Image</span>(systemName: <span class="hljs-string">"tray.fill"</span>)
                        <span class="hljs-type">Text</span>(<span class="hljs-string">"Inbox"</span>)
                    }
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .subtitle) {
                    <span class="hljs-type">Text</span>(<span class="hljs-string">"<span class="hljs-subst">\(selectedCount)</span> of <span class="hljs-subst">\(people.count)</span> people selected"</span>)
                        .foregroundStyle(.secondary)
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .status) {
                    <span class="hljs-type">Button</span> { } label: {
                        <span class="hljs-type">Text</span>(<span class="hljs-string">"<span class="hljs-subst">\(selectedCount)</span> of <span class="hljs-subst">\(people.count)</span> people selected"</span>)
                    }
                    .controlSize(.large)
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .bottomBar) {
                    <span class="hljs-type">Button</span> { } label: {
                        <span class="hljs-type">Label</span>(<span class="hljs-string">"Back"</span>, systemImage: <span class="hljs-string">"chevron.backward"</span>)
                    }
                }

                <span class="hljs-type">ToolbarItem</span>(placement: .bottomBar) {
                    <span class="hljs-type">Button</span> { } label: {
                        <span class="hljs-type">Label</span>(<span class="hljs-string">"Next"</span>, systemImage: <span class="hljs-string">"chevron.forward"</span>)
                    }
                }
            }
        }
    }
}
</code></pre><p><video src="https://img.kahwee.com/2025/swiftui-declarative-toolbar-placement/kitchen-sink.mp4" autoplay muted loop controls preload="metadata"></video></p>
<ul>
<li><code>.title</code> - Custom title view in navigation bar (replaces standard title)</li>
<li><code>.subtitle</code> - Subtitle text below title</li>
<li><code>.status</code> - Centered in bottom bar (can be button or text)</li>
</ul>
<h2>Portrait-Only iPhone Apps Don't Need This</h2>
<p>Here's my issue: <strong>if you're building a portrait-only iPhone app, SwiftUI's cross-platform adaptation solves a problem you don't have.</strong></p>
<p>I want fixed button placement on a simple iPhone app. SwiftUI's toolbar API assumes universal apps that might run on iPad or macOS, rotate to landscape, and need adaptive layouts.</p>
<p><strong>Apple's documentation doesn't make the constraints clear upfront.</strong> You discover them by testing. The docs say:</p>
<blockquote>
<p>"In compact horizontal size classes, the system limits both the leading and trailing positions of the navigation bar to a single item each."</p>
</blockquote>
<p>Translation: iPhone portrait gets <strong>one button</strong> in <code>.topBarLeading</code> and <strong>one button</strong> in <code>.topBarTrailing</code>. Want more? They go to overflow menus automatically.</p>
<div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"></path></svg>Note</p>
<p>In compact horizontal size classes (iPhone portrait), SwiftUI limits leading and trailing toolbar positions to one item each. Extra items silently move to overflow menus.</p>
</div>
<h2>Automatic Overflow with Too Many Items</h2>
<p>What happens when you add 10 toolbar buttons? SwiftUI creates an overflow menu. I built a demo with a star button (left) and 10 action icons (right):</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">struct</span> <span class="hljs-title class_">TenItemsTopTrailing</span>: <span class="hljs-title class_ inherited__">View</span> {
    <span class="hljs-meta">@State</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> isStarred <span class="hljs-operator">=</span> <span class="hljs-literal">false</span>
    <span class="hljs-meta">@State</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> showAlert <span class="hljs-operator">=</span> <span class="hljs-literal">false</span>
    <span class="hljs-meta">@State</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> alertMessage <span class="hljs-operator">=</span> <span class="hljs-string">""</span>

    <span class="hljs-keyword">private</span> <span class="hljs-keyword">let</span> trailingIcons <span class="hljs-operator">=</span> [
        <span class="hljs-string">"pencil"</span>, <span class="hljs-string">"trash"</span>, <span class="hljs-string">"square.and.arrow.up"</span>, <span class="hljs-string">"doc.on.doc"</span>,
        <span class="hljs-string">"folder"</span>, <span class="hljs-string">"tag"</span>, <span class="hljs-string">"link"</span>, <span class="hljs-string">"clock"</span>, <span class="hljs-string">"bell"</span>, <span class="hljs-string">"flag"</span>
    ]

    <span class="hljs-keyword">var</span> body: <span class="hljs-keyword">some</span> <span class="hljs-type">View</span> {
        <span class="hljs-type">NavigationStack</span> {
            <span class="hljs-type">List</span>(<span class="hljs-type">DemoData</span>.generatePeople(), id: \.<span class="hljs-keyword">self</span>) { person <span class="hljs-keyword">in</span>
                <span class="hljs-type">Text</span>(person)
            }
            .navigationTitle(<span class="hljs-string">"Lots of Actions"</span>)
            .alert(alertMessage, isPresented: <span class="hljs-variable">$showAlert</span>) {
                <span class="hljs-type">Button</span>(<span class="hljs-string">"OK"</span>) { }
            }
            .toolbar {
                <span class="hljs-type">ToolbarItem</span>(placement: .topBarLeading) {
                    <span class="hljs-type">Button</span> {
                        isStarred.toggle()
                        alertMessage <span class="hljs-operator">=</span> isStarred <span class="hljs-operator">?</span> <span class="hljs-string">"Starred"</span> : <span class="hljs-string">"Unstarred"</span>
                        showAlert <span class="hljs-operator">=</span> <span class="hljs-literal">true</span>
                    } label: {
                        <span class="hljs-type">Image</span>(systemName: isStarred <span class="hljs-operator">?</span> <span class="hljs-string">"star.fill"</span> : <span class="hljs-string">"star"</span>)
                            .foregroundColor(isStarred <span class="hljs-operator">?</span> .yellow : .primary)
                    }
                }

                <span class="hljs-type">ToolbarItemGroup</span>(placement: .topBarTrailing) {
                    <span class="hljs-type">ForEach</span>(trailingIcons, id: \.<span class="hljs-keyword">self</span>) { icon <span class="hljs-keyword">in</span>
                        <span class="hljs-type">Button</span> {
                            alertMessage <span class="hljs-operator">=</span> <span class="hljs-string">"<span class="hljs-subst">\(icon.capitalized)</span> clicked"</span>
                            showAlert <span class="hljs-operator">=</span> <span class="hljs-literal">true</span>
                        } label: {
                            <span class="hljs-type">Image</span>(systemName: icon)
                        }
                    }
                }
            }
        }
    }
}
</code></pre><p><video src="https://img.kahwee.com/2025/swiftui-declarative-toolbar-placement/overflow.mp4" autoplay muted loop controls preload="metadata"></video></p>
<p>I didn't write code to create the overflow menu. SwiftUI saw 10 items, measured available space, and moved extras into a "..." button. <strong>You get no control over this.</strong> No API to customize it, no way to prioritize which items stay visible, no option to change the overflow UI.</p>
<p>On iPhone, 4-5 icons show before overflow. On iPad, 5-7. On macOS, all 10 might fit if the window is wide enough. SwiftUI picks which items get hidden based on its own logic. Want to keep the most important actions visible? You can't specify that.</p>
<h2>Apple's Vision vs Developer Needs</h2>
<p><strong>Apple wants</strong> universal apps running on iPhone, iPad, macOS, and visionOS with consistent HIG adherence and automatic adaptation.</p>
<p><strong>Many developers want</strong> a simple iPhone portrait app with exact button placement, predictable behavior, and no hidden overflow menus.</p>
<p>These goals conflict. SwiftUI's toolbar API optimizes for universal apps that adapt everywhere. For simple iPhone apps, it creates friction. <strong>The framework makes decisions for you</strong>, and you can't override them.</p>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2025/understanding-swiftui-tabview/">Understanding SwiftUI's TabView</a> - Learn how tab bars work with the iOS 26 Tab API</li>
<li><a href="/2025/swiftui-accessibility-reduce-motion-voiceover-part-1/">SwiftUI Liquid Glass and Accessibility</a> - Make your toolbars accessible with proper VoiceOver support</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Understanding SwiftUI's TabView]]></title>
      <link>https://kahwee.com/2025/understanding-swiftui-tabview/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/understanding-swiftui-tabview/</guid>
      <pubDate>Sat, 11 Oct 2025 17:00:00 GMT</pubDate>
      <description><![CDATA[SwiftUI TabView deep dive: Discover how iOS 26 Tab API actually works with liquid glass effects and special tab roles. Visual explanations included.]]></description>
      <category>swiftui</category>
      <category>ios</category>
      <category>mobile-development</category>
      <category>tutorial</category>
      <category>liquid-glass</category>
      <content:encoded><![CDATA[<p>Apple's documentation for the iOS 26 Tab API is text-heavy with few visuals. I'm a beginner iOS developer, so I built several examples and recorded them to understand how TabView actually behaves.</p>
<h2>API Redesign</h2>
<p>iOS 26 replaced <code>.tabItem</code> modifiers with a dedicated <code>Tab</code> component. The old API required wrapping labels in closures. The new syntax consolidates everything:</p>
<p><strong>Before:</strong></p>
<pre><code class="hljs language-swift"><span class="hljs-type">Text</span>(<span class="hljs-string">"Home"</span>).tabItem { <span class="hljs-type">Label</span>(<span class="hljs-string">"Home"</span>, systemImage: <span class="hljs-string">"house"</span>) }
</code></pre><p><strong>After:</strong></p>
<pre><code class="hljs language-swift"><span class="hljs-type">Tab</span>(<span class="hljs-string">"Home"</span>, systemImage: <span class="hljs-string">"house"</span>) { <span class="hljs-type">Text</span>(<span class="hljs-string">"Home"</span>) }
</code></pre><p>The result is less nesting and clearer intent.</p>
<h2>Example Setup</h2>
<p>For visual consistency across examples, I extracted styling into <code>TabViewHelpers.swift</code>:</p>
<pre><code class="hljs language-swift"><span class="hljs-comment">// TabViewHelpers.swift - shared across all examples</span>
<span class="hljs-keyword">extension</span> <span class="hljs-title class_">View</span> {
    <span class="hljs-keyword">var</span> rainbowGradient: <span class="hljs-keyword">some</span> <span class="hljs-type">View</span> {
        <span class="hljs-type">LinearGradient</span>(
            colors: [.red, .orange, .yellow, .green, .blue, .purple],
            startPoint: .topLeading,
            endPoint: .bottomTrailing
        )
        .ignoresSafeArea()
    }

    <span class="hljs-keyword">func</span> <span class="hljs-title function_">tabContent</span>(<span class="hljs-keyword">_</span> <span class="hljs-params">text</span>: <span class="hljs-type">String</span>) -&gt; <span class="hljs-keyword">some</span> <span class="hljs-type">View</span> {
        <span class="hljs-type">Text</span>(text)
            .font(.system(size: <span class="hljs-number">40</span>, weight: .bold))
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
            .padding(.bottom, <span class="hljs-number">100</span>)
            .background(rainbowGradient)
    }
}
</code></pre><p>This keeps examples focused on TabView structure. The rainbow gradient makes liquid glass effects more visible in videos.</p>
<h2>Basic Tab Example</h2>
<p>A standard TabView with three tabs:</p>
<pre><code class="hljs language-swift"><span class="hljs-type">TabView</span> {
    <span class="hljs-type">Tab</span>(<span class="hljs-string">"Home"</span>, systemImage: <span class="hljs-string">"house"</span>) {
        tabContent(<span class="hljs-string">"Home"</span>)
    }
    <span class="hljs-type">Tab</span>(<span class="hljs-string">"Automation"</span>, systemImage: <span class="hljs-string">"checkmark.circle"</span>) {
        tabContent(<span class="hljs-string">"Automation"</span>)
    }
    <span class="hljs-type">Tab</span>(<span class="hljs-string">"Discover"</span>, systemImage: <span class="hljs-string">"star"</span>) {
        tabContent(<span class="hljs-string">"Discover"</span>)
    }
}
</code></pre><p>Each <code>Tab</code> needs a label, icon, and content. TabView handles state persistence, animations, and accessibility automatically.</p>
<p>iOS 26 introduced "liquid-glass"—a frosted tab bar that adapts to content behind it. The effect is automatic. As you switch tabs, the backdrop blur adjusts dynamically. No configuration is needed.</p>
<p>Notice how the gradient shifts through the frosted tab bar as you navigate:</p>
<p><video src="https://img.kahwee.com/2025/understanding-swiftui-tabview/tabview.mp4" autoplay muted loop controls preload="metadata"></video></p>
<h2>Search Role</h2>
<p><code>Tab(role: .search)</code> is special. The search role automatically provides the magnifying glass icon and semantic context to iOS:</p>
<pre><code class="hljs language-swift"><span class="hljs-type">TabView</span> {
    <span class="hljs-type">Tab</span>(<span class="hljs-string">"Issues"</span>, systemImage: <span class="hljs-string">"newspaper"</span>) {
        tabContent(<span class="hljs-string">"Issues"</span>)
    }
    <span class="hljs-type">Tab</span>(<span class="hljs-string">"About"</span>, systemImage: <span class="hljs-string">"info.circle"</span>) {
        tabContent(<span class="hljs-string">"About"</span>)
    }
    <span class="hljs-type">Tab</span>(role: .search) {
        tabContent(<span class="hljs-string">"Search"</span>)
    }
}
</code></pre><p>The magnifying glass is locked in—no customization. This ensures consistent search recognition across iOS. Apps like App Store and Music follow this pattern.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Only one <code>Tab(role: .search)</code> is allowed per TabView. Adding a second one compiles but produces undefined behavior at runtime.</p>
</div>
<p><video src="https://img.kahwee.com/2025/understanding-swiftui-tabview/search.mp4" autoplay muted loop controls preload="metadata"></video></p>
<h2>Badges</h2>
<p><code>.badge()</code> adds notification bubbles to tabs:</p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">struct</span> <span class="hljs-title class_">BadgedTabView</span>: <span class="hljs-title class_ inherited__">View</span> {
    <span class="hljs-meta">@State</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> messageCount <span class="hljs-operator">=</span> <span class="hljs-number">3</span>
    <span class="hljs-meta">@State</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> notificationCount <span class="hljs-operator">=</span> <span class="hljs-number">7</span>

    <span class="hljs-keyword">var</span> body: <span class="hljs-keyword">some</span> <span class="hljs-type">View</span> {
        <span class="hljs-type">VStack</span> {
            <span class="hljs-comment">// Control buttons</span>
            <span class="hljs-type">HStack</span> {
                <span class="hljs-type">Button</span>(<span class="hljs-string">"Add Message"</span>) {
                    messageCount <span class="hljs-operator">+=</span> <span class="hljs-number">1</span>
                }
                <span class="hljs-type">Button</span>(<span class="hljs-string">"Clear Notifications"</span>) {
                    notificationCount <span class="hljs-operator">=</span> <span class="hljs-number">0</span>
                }
            }
            .padding()

            <span class="hljs-type">TabView</span> {
                <span class="hljs-type">Tab</span>(<span class="hljs-string">"Messages"</span>, systemImage: <span class="hljs-string">"message"</span>) {
                    tabContent(<span class="hljs-string">"Messages (<span class="hljs-subst">\(messageCount)</span>)"</span>)
                }
                .badge(messageCount)

                <span class="hljs-type">Tab</span>(<span class="hljs-string">"Notifications"</span>, systemImage: <span class="hljs-string">"bell"</span>) {
                    tabContent(<span class="hljs-string">"Notifications (<span class="hljs-subst">\(notificationCount)</span>)"</span>)
                }
                .badge(notificationCount)

                <span class="hljs-type">Tab</span>(<span class="hljs-string">"Profile"</span>, systemImage: <span class="hljs-string">"person"</span>) {
                    tabContent(<span class="hljs-string">"Profile"</span>)
                }
            }
        }
    }
}
</code></pre><p>Badges hide at 0, and iOS handles positioning and styling. Messages, Mail, and Phone use this pattern for unread counts.</p>
<p><video src="https://img.kahwee.com/2025/understanding-swiftui-tabview/badge.mp4" autoplay muted loop controls preload="metadata"></video></p>
<h2>What You Can't Customize</h2>
<p>Apple enforces conventions to maintain iOS consistency.</p>
<h3>Search Is Locked Down</h3>
<p><code>Tab(role: .search)</code> cannot be repurposed. You can't replace it with an "Add" button. The icon is fixed, only one search role is allowed per TabView, and iOS determines its position.</p>
<p>Apple's reasoning: search should be instantly recognizable. The magnifying glass is universal.</p>
<h3>Other Restrictions</h3>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>iOS 26 forces tab bars near the top on iPad, and liquid glass styling cannot be disabled. Test on both iPhone and iPad to confirm your layout adapts.</p>
</div>
<p><strong>More than 5 tabs</strong>: iOS adds a "More" tab automatically. You can't customize or remove it. The system decides which tabs get hidden.</p>
<p><strong>iPad layout</strong>: iOS 26 forces tab bars near the top. The old bottom position is gone. Sidebar toggle is system-controlled.</p>
<p><strong>Tab bar appearance</strong>: Limited color, blur, and spacing customization. Liquid glass is the default—you can't disable it or swap in custom materials.</p>
<p><strong>Tab ordering</strong>: Users can reorder in the "More" menu (requires <code>customizationID</code>). Sidebar doesn't always sync with TabView order.</p>
<p>Most apps stay within these constraints. The defaults cover common cases and maintain iOS consistency.</p>
<h2>Design Guidelines</h2>
<p>Apple's recommendations:</p>
<p><strong>Structure</strong></p>
<ul>
<li>3-5 tabs on iPhone</li>
<li>Clear SF Symbol icons</li>
<li>Concise titles (1-2 words)</li>
<li>Sparse badging (only for things that need attention)</li>
</ul>
<p><strong>Hierarchy</strong></p>
<ul>
<li>Primary actions in main tabs</li>
<li>Secondary content in TabSections (iOS 26+)</li>
<li>Search always uses <code>Tab(role: .search)</code></li>
</ul>
<p><strong>Responsive</strong></p>
<ul>
<li>Size class adaptation for different screens</li>
<li><code>.sidebarAdaptable</code> for iPad</li>
<li>44pt minimum touch targets</li>
</ul>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2025/swiftui-declarative-toolbar-placement/">SwiftUI's Toolbar placement API</a> - Learn how to customize toolbar buttons and bottom bars in SwiftUI</li>
<li><a href="/2025/swiftui-accessibility-reduce-motion-voiceover-part-1/">SwiftUI Liquid Glass and Accessibility</a> - Make your tab views accessible with Reduce Motion and VoiceOver support</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[SwiftUI Liquid Glass and Accessibility - Part 2]]></title>
      <link>https://kahwee.com/2025/swiftui-accessibility-reduce-motion-voiceover-part-2/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/swiftui-accessibility-reduce-motion-voiceover-part-2/</guid>
      <pubDate>Thu, 09 Oct 2025 17:01:00 GMT</pubDate>
      <description><![CDATA[iOS 26 Liquid Glass accessibility: VoiceOver labels, hints, grouping, and comprehensive testing strategies for accessible glass effects.]]></description>
      <category>mobile-development</category>
      <category>technology</category>
      <category>swiftui</category>
      <content:encoded><![CDATA[<p>This is Part 2 covering VoiceOver support and testing. <a href="/2025/swiftui-accessibility-reduce-motion-voiceover-part-1/">Part 1</a> covered Reduce Motion and Dynamic Type.</p>
<h2>VoiceOver Labels, Hints, and Grouping</h2>
<p>VoiceOver reads UI elements aloud for blind and low-vision users. SwiftUI provides default labels for text and images, but custom views need explicit accessibility annotations. Without proper labels, VoiceOver reads raw view hierarchies ("Image, Text, Text, Text") instead of meaningful descriptions.</p>
<p><strong>Combining elements:</strong> Group related views so VoiceOver reads them as one logical element:</p>
<pre><code class="hljs language-swift"><span class="hljs-type">HStack</span> {
    <span class="hljs-type">Image</span>(systemName: <span class="hljs-string">"photo"</span>)
        .resizable()
        .frame(width: <span class="hljs-number">60</span>, height: <span class="hljs-number">60</span>)

    <span class="hljs-type">VStack</span>(alignment: .leading) {
        <span class="hljs-type">Text</span>(item.name)
            .font(.headline)
        <span class="hljs-type">Text</span>(<span class="hljs-string">"<span class="hljs-subst">\(item.quantity)</span> items"</span>)
            .font(.subheadline)
        <span class="hljs-keyword">if</span> <span class="hljs-keyword">let</span> notes <span class="hljs-operator">=</span> item.notes {
            <span class="hljs-type">Text</span>(notes)
                .font(.caption)
        }
    }
}
.accessibilityElement(children: .combine)
.accessibilityLabel(<span class="hljs-string">"<span class="hljs-subst">\(item.name)</span>, <span class="hljs-subst">\(item.quantity)</span> items"</span>)
.accessibilityHint(item.notes <span class="hljs-operator">??</span> <span class="hljs-string">"No notes"</span>)
.accessibilityValue(item.condition<span class="hljs-operator">?</span>.rawValue <span class="hljs-operator">??</span> <span class="hljs-string">""</span>)
</code></pre><p><code>.accessibilityElement(children: .combine)</code> merges child elements into one. <code>.accessibilityLabel()</code> is the primary text VoiceOver reads immediately—keep it short. <code>.accessibilityHint()</code> is secondary context read after a delay. <code>.accessibilityValue()</code> holds current state.</p>
<p><strong>Stat Card with Icon:</strong></p>
<pre><code class="hljs language-swift"><span class="hljs-type">VStack</span> {
    <span class="hljs-type">Image</span>(systemName: <span class="hljs-string">"star.fill"</span>)
        .font(.title)
    <span class="hljs-type">Text</span>(<span class="hljs-string">"<span class="hljs-subst">\(count)</span>"</span>)
        .font(.title2)
        .fontWeight(.bold)
    <span class="hljs-type">Text</span>(label)
        .font(.caption)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(<span class="hljs-string">"<span class="hljs-subst">\(label)</span>: <span class="hljs-subst">\(count)</span>"</span>)
</code></pre><p>Without <code>.accessibilityElement(children: .combine)</code>, VoiceOver reads "Star, 42, Favorites". With grouping: "Favorites: 42".</p>
<h2>iOS 26 Liquid Glass Defaults</h2>
<p><code>.glassEffect()</code> automatically respects Reduce Motion and Reduce Transparency system settings. Liquid Glass materials adapt opacity and blur intensity based on accessibility preferences. <code>GlassEffectContainer</code> groups glass views for shared rendering and better performance. <code>.interactive()</code> makes glass respond to touch and focus with subtle lighting—disabled when Reduce Motion is on.</p>
<p>Apply <code>.glassEffect()</code> to floating elements (toolbars, tab bars, cards). Avoid placing solid fills behind glass views. Don't combine <code>.glassEffect()</code> with manual <code>.blur()</code> or <code>.opacity()</code> modifiers. Use <code>GlassEffectContainer</code> when multiple glass elements share the same space.</p>
<h2>Testing Your Implementation</h2>
<p><strong>Reduce Motion:</strong> Settings -&gt; Accessibility -&gt; Motion -&gt; Reduce Motion -&gt; ON. Verify <code>.glassEffect()</code> is replaced with static materials. Check that parallax and refraction animations are disabled.</p>
<p><strong>Reduce Transparency:</strong> Settings -&gt; Accessibility -&gt; Display &amp; Text Size -&gt; Reduce Transparency -&gt; ON. Glass elements should become more opaque and frosty. Content behind glass should be more obscured for better contrast.</p>
<p><strong>Dynamic Type:</strong> Settings -&gt; Accessibility -&gt; Display &amp; Text Size -&gt; Larger Text. Drag the slider to maximum. Check that text on glass backgrounds stays readable and layouts don't break.</p>
<p><strong>VoiceOver:</strong> Settings -&gt; Accessibility -&gt; VoiceOver -&gt; Enable. Navigate through glass UI elements. Listen for meaningful descriptions, not just "Glass effect, Button".</p>
<p><strong>Combined testing:</strong> Enable Reduce Motion + Reduce Transparency + Dark Mode together for the most accessibility-friendly configuration.</p>
<h2>What I Learned</h2>
<p>Accessibility isn't an afterthought in Liquid Glass—Apple baked it in from the start. But you have to respect it in custom implementations.</p>
<p>The <code>@Environment(\.accessibilityReduceMotion)</code> pattern is straightforward. When Reduce Motion is on, I replace <code>.glassEffect()</code> with <code>.ultraThinMaterial</code> for a static blur without morphing animations. VoiceOver grouping with <code>.accessibilityElement(children: .combine)</code> solved screen readers treating each glass element separately. Dynamic Type worked automatically once I switched from fixed font sizes to SwiftUI's built-in text styles.</p>
<p>Liquid Glass looks incredible but is visually intense. Some users find it distracting or hard to read. Testing with Reduce Motion + Reduce Transparency showed me how much the system adapts—glass becomes more opaque, animations stop, and the UI feels closer to iOS 25's flat design.</p>
<p>What I'd do differently next time: check <code>accessibilityReduceMotion</code> and fall back to <code>.ultraThinMaterial</code> instead of <code>.glassEffect()</code>, use SwiftUI text styles with <code>dynamicTypeSize</code> ranges for text on glass backgrounds, group compound glass views with <code>accessibilityElement(children: .combine)</code>, test with both Reduce Motion and Reduce Transparency enabled, and never manually combine <code>.glassEffect()</code> with <code>.blur()</code> or <code>.opacity()</code>.</p>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>Test with both Reduce Motion and Reduce Transparency enabled simultaneously. That combination reveals the most about how your glass UI degrades.</p>
</div>
<p>The hard part is not the API itself but remembering to test with those settings enabled.</p>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2025/swiftui-accessibility-reduce-motion-voiceover-part-1/">Part 1: Reduce Motion and Dynamic Type</a> - Foundation accessibility techniques for Liquid Glass</li>
<li><a href="/2025/understanding-swiftui-tabview/">Understanding SwiftUI's TabView</a> - Apply these accessibility patterns to tab views</li>
<li><a href="/2025/swiftui-declarative-toolbar-placement/">SwiftUI Bottom Toolbar and Placement API</a> - Make toolbars accessible with proper placement</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[SwiftUI Liquid Glass and Accessibility - Part 1]]></title>
      <link>https://kahwee.com/2025/swiftui-accessibility-reduce-motion-voiceover-part-1/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/swiftui-accessibility-reduce-motion-voiceover-part-1/</guid>
      <pubDate>Thu, 09 Oct 2025 17:00:00 GMT</pubDate>
      <description><![CDATA[iOS 26 Liquid Glass accessibility: Implementing Reduce Motion and Dynamic Type support for blur, refraction, and parallax effects.]]></description>
      <category>mobile-development</category>
      <category>technology</category>
      <category>swiftui</category>
      <content:encoded><![CDATA[<p>I started building a UI with iOS 26's <a href="https://www.apple.com/newsroom/2025/06/apple-introduces-a-delightful-and-elegant-new-software-design/" target="_blank" rel="noopener noreferrer nofollow">Liquid Glass design language</a>—<code>.glassEffect()</code> modifiers, blur animations, refraction, and parallax. The whole glass effect gets distracting after a while. I wanted Reduce Motion to work properly. This is Part 1 covering Reduce Motion and Dynamic Type. <a href="/2025/swiftui-accessibility-reduce-motion-voiceover-part-2/">Part 2</a> covers VoiceOver and testing.</p>
<h2>What Liquid Glass Does</h2>
<p>iOS 26 introduces <strong>Liquid Glass</strong>, Apple's unified design language influenced by visionOS. It replaces iOS 7's flat design with rounded, translucent elements that refract, blur, and respond to motion, content, and user input.</p>
<p>The visual properties: environmental refraction (refracts and reflects content behind it), dynamic light reflection (responds to light sources), responsive fluidity (morphs based on size and interaction), and adaptive blur (adapts to background content). Larger glass elements simulate thicker glass with deeper shadows and more pronounced lensing.</p>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>iOS 26's Liquid Glass design can cause accessibility issues if not handled properly. Blur, refraction, and parallax effects may trigger motion sickness for users with vestibular disorders.</p>
</div>
<p>iOS 26's Liquid Glass blur, refraction, and parallax effects break accessibility when unhandled. Three areas matter: Reduce Motion (disable or simplify animations), Reduce Transparency (make glass less translucent for better contrast), and VoiceOver (give glass-styled components proper labels).</p>
<p>Apple built accessibility into Liquid Glass directly. Reduce Motion decreases glass effect intensity, disables elastic properties, and removes parallax and refraction animations. Reduce Transparency makes Liquid Glass "frostier" and more opaque, obscuring more content behind glass to improve contrast. Combined settings (Reduce Transparency + Reduce Motion + Dark Mode) deliver the best visibility, performance, and battery life. Liquid Glass then behaves more like iOS 25's flat design.</p>
<h2>Disable Liquid Glass Animations with Reduce Motion</h2>
<p>When users enable Reduce Motion, disable or simplify blur animations, parallax, refraction, and elastic morphing. Liquid Glass's dynamic refraction and morphing animations can trigger motion sickness in users with vestibular disorders.</p>
<p><strong>Implementation:</strong></p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">struct</span> <span class="hljs-title class_">AnimatedBadge</span>: <span class="hljs-title class_ inherited__">View</span> {
    <span class="hljs-meta">@Environment</span>(\.accessibilityReduceMotion) <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> reduceMotion
    <span class="hljs-meta">@State</span> <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> animate <span class="hljs-operator">=</span> <span class="hljs-literal">false</span>

    <span class="hljs-keyword">var</span> body: <span class="hljs-keyword">some</span> <span class="hljs-type">View</span> {
        <span class="hljs-keyword">let</span> animation <span class="hljs-operator">=</span> reduceMotion
            <span class="hljs-operator">?</span> .none
            : .easeInOut(duration: <span class="hljs-number">0.8</span>).repeatForever(autoreverses: <span class="hljs-literal">true</span>)

        <span class="hljs-type">Circle</span>()
            .fill(<span class="hljs-type">Color</span>.blue)
            .frame(width: <span class="hljs-number">12</span>, height: <span class="hljs-number">12</span>)
            .scaleEffect(animate <span class="hljs-operator">?</span> <span class="hljs-number">1.2</span> : <span class="hljs-number">1.0</span>)
            .onAppear {
                <span class="hljs-keyword">guard</span> <span class="hljs-operator">!</span>reduceMotion <span class="hljs-keyword">else</span> { <span class="hljs-keyword">return</span> }
                withAnimation(animation) {
                    animate <span class="hljs-operator">=</span> <span class="hljs-literal">true</span>
                }
            }
    }
}
</code></pre><p>The pattern: read <code>@Environment(\.accessibilityReduceMotion)</code>, set animation to <code>.none</code> when enabled, and early return in <code>onAppear</code> to prevent state changes.</p>
<p><strong>Liquid Glass with Reduce Motion:</strong></p>
<pre><code class="hljs language-swift"><span class="hljs-keyword">struct</span> <span class="hljs-title class_">GlassCard</span>: <span class="hljs-title class_ inherited__">View</span> {
    <span class="hljs-meta">@Environment</span>(\.accessibilityReduceMotion) <span class="hljs-keyword">private</span> <span class="hljs-keyword">var</span> reduceMotion

    <span class="hljs-keyword">var</span> body: <span class="hljs-keyword">some</span> <span class="hljs-type">View</span> {
        <span class="hljs-type">VStack</span> {
            <span class="hljs-type">Text</span>(<span class="hljs-string">"Content"</span>)
        }
        .padding()
        .background {
            <span class="hljs-keyword">if</span> reduceMotion {
                <span class="hljs-comment">// Fallback: simple blur without morphing</span>
                <span class="hljs-type">RoundedRectangle</span>(cornerRadius: <span class="hljs-number">16</span>)
                    .fill(.ultraThinMaterial)
            } <span class="hljs-keyword">else</span> {
                <span class="hljs-comment">// Full Liquid Glass effect</span>
                <span class="hljs-type">RoundedRectangle</span>(cornerRadius: <span class="hljs-number">16</span>)
                    .glassEffect(.regular.interactive())
            }
        }
    }
}
</code></pre><p>When Reduce Motion is on, replace <code>.glassEffect()</code> with <code>.ultraThinMaterial</code> or <code>.regularMaterial</code>. You keep the blur without the morphing and refraction.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Do not wrap the Reduce Motion check in <code>onAppear</code> alone. If the user toggles Reduce Motion while your app is open, <code>@Environment</code> updates the view automatically but <code>onAppear</code> does not re-fire.</p>
</div>
<h2>Scalable Text with Dynamic Type</h2>
<p>Dynamic Type lets users adjust text size system-wide. SwiftUI text styles (<code>.headline</code>, <code>.body</code>, <code>.caption</code>) scale automatically, but you still need to handle layout constraints. Users with visual impairments rely on larger sizes up to <code>accessibility5</code>.</p>
<p><strong>Implementation:</strong></p>
<pre><code class="hljs language-swift"><span class="hljs-type">Text</span>(item.name)
    .font(.headline)
    .fontWeight(.semibold)
    .lineLimit(<span class="hljs-number">2</span>)
    .multilineTextAlignment(.leading)
    .dynamicTypeSize(<span class="hljs-operator">...</span><span class="hljs-type">DynamicTypeSize</span>.accessibility5)
</code></pre><p>Use SwiftUI's built-in text styles instead of fixed point sizes. The range operator <code>...DynamicTypeSize.accessibility5</code> caps the maximum text size. Allow at least 2 lines to prevent truncation at larger sizes. Test with Settings -&gt; Accessibility -&gt; Display &amp; Text Size -&gt; Larger Text.</p>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>Sometimes unlimited scaling breaks layouts. Use <code>.dynamicTypeSize(.medium...DynamicTypeSize.xxxLarge)</code> to cap at a maximum size while still supporting accessibility.</p>
</div>
<p><strong>Capping Dynamic Type:</strong> You can cap at a maximum size when unlimited scaling breaks layouts:</p>
<pre><code class="hljs language-swift"><span class="hljs-type">Text</span>(<span class="hljs-string">"Fixed Layout Text"</span>)
    .font(.body)
    .dynamicTypeSize(.medium<span class="hljs-operator">...</span><span class="hljs-type">DynamicTypeSize</span>.xxxLarge)
</code></pre><p>This allows scaling up to <code>xxxLarge</code> but prevents accessibility sizes that might break the design.</p>
<p>Continue to <a href="/2025/swiftui-accessibility-reduce-motion-voiceover-part-2/">Part 2</a> for VoiceOver support and testing strategies.</p>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2025/understanding-swiftui-tabview/">Understanding SwiftUI's TabView</a> - See how to implement accessible tab bars with Liquid Glass</li>
<li><a href="/2025/swiftui-declarative-toolbar-placement/">SwiftUI Bottom Toolbar and Placement API</a> - Learn toolbar placement patterns that work with accessibility settings</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Rewriting /tag/* to /tags/* with Cloudflare Workers]]></title>
      <link>https://kahwee.com/2025/cloudflare-workers-redirect/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/cloudflare-workers-redirect/</guid>
      <pubDate>Thu, 09 Oct 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[A Worker catches requests to example.com/tag/ and sends them to example.com/tags/, keeping the method, headers, and query strings intact. No changes to the main app. People bookmark URLs, and when...]]></description>
      <category>cloudflare</category>
      <category>web-development</category>
      <content:encoded><![CDATA[<h3>What this does</h3>
<p>A Worker catches requests to example.com/tag/_ and sends them to example.com/tags/_, keeping the method, headers, and query strings intact. No changes to the main app.</p>
<p>People bookmark URLs, and when you rename a path, their links break. This Worker fixes that problem.</p>
<h3>Worker script</h3>
<pre><code class="hljs language-javascript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> {
  <span class="hljs-keyword">async</span> <span class="hljs-title function_">fetch</span>(<span class="hljs-params">request</span>) {
    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title function_">URL</span>(request.<span class="hljs-property">url</span>);

    <span class="hljs-comment">// Only handle /tag/*, leave /tags/* and everything else alone</span>
    <span class="hljs-keyword">if</span> (url.<span class="hljs-property">pathname</span>.<span class="hljs-title function_">startsWith</span>(<span class="hljs-string">"/tag/"</span>)) {
      url.<span class="hljs-property">pathname</span> = url.<span class="hljs-property">pathname</span>.<span class="hljs-title function_">replace</span>(<span class="hljs-regexp">/^\/tag\//</span>, <span class="hljs-string">"/tags/"</span>);
      <span class="hljs-keyword">return</span> <span class="hljs-title function_">fetch</span>(url.<span class="hljs-title function_">toString</span>(), request);
    }

    <span class="hljs-keyword">return</span> <span class="hljs-title function_">fetch</span>(request);
  },
};
</code></pre><p>This uses modules style with a basic path check, where the regex swaps /tag/ for /tags/ before passing the request along.</p>
<h3>Create the Worker</h3>
<p>In the Cloudflare dashboard, go to <a href="https://developers.cloudflare.com/workers/get-started/dashboard/" target="_blank" rel="noopener noreferrer nofollow">Workers &amp; Pages -&gt; Create application -&gt; Create Worker</a>. Open the Worker, pick Edit code, paste the script, then Save and deploy.</p>
<h3>Attach a route</h3>
<p>In the Worker, go to <a href="https://developers.cloudflare.com/workers/configuration/routing/routes/" target="_blank" rel="noopener noreferrer nofollow">Settings -&gt; Domains &amp; Routes -&gt; Add -&gt; Route</a>. Pick the zone example.com and set the route to example.com/tag/_ so it only rewrites under /tag/_.</p>
<h3>DNS and proxy prerequisites</h3>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>Your domain needs an orange-cloud proxied DNS record in Cloudflare. Without it, requests bypass Cloudflare entirely and the Worker never runs.</p>
</div>
<h3>Patterns and precedence</h3>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>This Worker does an internal rewrite, not an HTTP redirect. The user's URL bar still shows <code>/tag/</code>. If you want a 301 redirect for SEO, return <code>Response.redirect()</code> instead of <code>fetch()</code>.</p>
</div>
<p>Route patterns work with wildcards at the start of hostnames or end of paths, like example.com/tag/<em>. Putting wildcards mid-path (example.com/</em>/tag/*) is invalid. Specific routes win over general ones.</p>
<h3>Optional: broader coverage</h3>
<p>Set the route to example.com/_ and keep the code check so it only rewrites /tag/_. Other paths pass through, and the Worker runs more often but you manage fewer routes.</p>
<h3>Quick edits in the dashboard</h3>
<p>For small tweaks, use the dashboard's Quick Edit (VS Code in the browser) to change code, preview, and redeploy without local tools. Save Wrangler for CI/CD or bigger projects.</p>
<h3>Verification</h3>
<p>Requests to <a href="https://example.com/tag/some-topic" target="_blank" rel="noopener noreferrer nofollow">https://example.com/tag/some-topic</a> should show content from <a href="https://example.com/tags/some-topic" target="_blank" rel="noopener noreferrer nofollow">https://example.com/tags/some-topic</a>, with query strings intact and caching handled upstream.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Migrating from Cloudflare Pages to Workers - Do You Even Need To?]]></title>
      <link>https://kahwee.com/2025/migrating-from-cloudflare-pages-to-workers/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/migrating-from-cloudflare-pages-to-workers/</guid>
      <pubDate>Wed, 08 Oct 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Cloudflare is consolidating Pages into Workers. Here's the one-line config change required to migrate, what it actually costs, and whether you should bother for a static blog.]]></description>
      <category>cloudflare</category>
      <category>web-development</category>
      <category>performance</category>
      <content:encoded><![CDATA[<p>Cloudflare is merging Pages into Workers. For static sites, the migration is a one-line config change. The real question: do you need to migrate at all?</p>
<h2>What It Costs</h2>
<p>Static hosting is <strong>free on both platforms</strong>. Page count does not matter.</p>
<p><strong>Pages charges for builds:</strong></p>
<ul>
<li>Free: 500 builds/month</li>
<li>Pro: $20/month for 5,000 builds</li>
</ul>
<p><strong>Workers charges for server-side execution:</strong></p>
<ul>
<li>Free: 100,000 requests/day</li>
<li>Paid: $5/month for 10M requests</li>
</ul>
<p><strong>For a static blog:</strong> both cost $0/month unless you exceed 500 builds or add dynamic features. I have been on the free tier for years.</p>
<h2>One Line to Migrate</h2>
<p>Pages already runs on Workers infrastructure. Change one property in your config:</p>
<pre><code class="hljs language-diff"><span class="hljs-deletion">- pages_build_output_dir: "./dist"</span>
<span class="hljs-addition">+ assets:</span>
<span class="hljs-addition">+   directory: "./dist"</span>
</code></pre><p>Then redeploy the site.</p>
<p>Full steps:</p>
<ol>
<li>Install Wrangler: <code>bun install -D wrangler@latest</code></li>
<li>Update your config (see above)</li>
<li>Configure API token permissions (see below)</li>
<li>Deploy: <code>bunx wrangler deploy</code></li>
</ol>
<p>The <a href="https://developers.cloudflare.com/workers/static-assets/migrate-from-pages/" target="_blank" rel="noopener noreferrer nofollow">official migration guide</a> covers complex setups with Functions. Most static sites will not need it.</p>
<h3>API Token Permissions</h3>
<p>If you deploy with an API token (recommended for CI/CD), you need these permissions:</p>
<p><strong>Required:</strong></p>
<ul>
<li><strong>Account -&gt; Cloudflare Pages -&gt; Edit</strong> (for Pages deployments)</li>
<li><strong>Account -&gt; Workers Scripts -&gt; Edit</strong> (for Workers deployments)</li>
<li><strong>Account -&gt; Account Settings -&gt; Read</strong> (for account info)</li>
</ul>
<p><strong>Optional but recommended:</strong></p>
<ul>
<li><strong>Zone -&gt; Workers Routes -&gt; Edit</strong> (if using custom domains with your zone)</li>
</ul>
<p><strong>Setting up the token:</strong></p>
<ol>
<li>Go to <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener noreferrer nofollow">Cloudflare Dashboard -&gt; API Tokens</a></li>
<li>Click "Create Token"</li>
<li>Add the permissions above</li>
<li>For Zone permissions, select "Specific zone" and choose your domain (e.g., <code>kahwee.com</code>)</li>
<li>Save the token and set it as your <code>CLOUDFLARE_API_TOKEN</code> environment variable</li>
</ol>
<h2>What Workers Adds</h2>
<p><strong>Node.js API compatibility:</strong> <code>crypto</code>, <code>tls</code>, <code>net</code>, <code>dns</code> modules. Better support for frameworks expecting Node APIs.</p>
<p><strong>Edge compute features:</strong></p>
<ul>
<li>Durable Objects (real-time collaboration, WebSocket state)</li>
<li>Cron Triggers (scheduled tasks at the edge)</li>
<li>Queue Consumers (background job processing)</li>
<li>Gradual Deployments (progressive rollouts)</li>
</ul>
<p><strong>Observability:</strong> Workers Logs with request-level detail, Logpush for analytics pipelines, Source Maps for debugging.</p>
<h2>Does a Blog Need This?</h2>
<div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"></path></svg>Note</p>
<p><strong>Honest answer: probably not.</strong></p>
</div>
<p>If you serve pre-built HTML, CSS, and JavaScript, both platforms are free and fast. The migration is trivial. What do you actually gain?</p>
<p><strong>Form handling</strong> is the main reason to migrate. Contact forms, newsletter signups, comment APIs — you handle form posts without a separate backend service.</p>
<p><strong>API endpoints</strong> like search, view counts, and reading time tracking. Workers builds lightweight APIs alongside your blog without a server.</p>
<p><strong>Dynamic content</strong> like personalized pages, A/B testing, and geo-redirects. Useful for experimentation. Overkill for most blogs.</p>
<p>Skip Durable Objects unless you are building live comments or a collaborative editor. Skip Cron Triggers — most static site generators handle scheduled publishing at build time. Skip Queue Consumers entirely.</p>
<h2>When to Migrate</h2>
<p>Migrate if you plan to add comments, search, or dynamic features. Migrate if you want lightweight APIs for likes, subscriptions, or view counts. Migrate if you need better observability than Pages offers.</p>
<p>Stay on Pages if your blog is purely static and will remain so.</p>
<h2>My Take</h2>
<p>I migrated because I wanted newsletter signups without third-party services. The migration took 5 minutes. Most of that was waiting for deployment.</p>
<p>If you are just serving markdown-to-HTML, do not bother. Pages works fine for static sites. The migration is simple enough to do anytime you need Workers features.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Setting Up Home Assistant Connect ZWA-2]]></title>
      <link>https://kahwee.com/2025/setting-up-zwave-home-assistant/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/setting-up-zwave-home-assistant/</guid>
      <pubDate>Tue, 07 Oct 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Setup Home Assistant Connect ZWA-2: Connect Z-Wave USB to Ugreen NAS via Docker and Z-Wave JS UI. Complete smart home control guide.]]></description>
      <category>personal</category>
      <category>technology</category>
      <content:encoded><![CDATA[<p>This guide covers how I set up Home Assistant Connect ZWA-2 with Z-Wave JS UI on a Ugreen NAS using Docker.</p>
<h2>Hardware Setup</h2>
<p>The Connect ZWA-2 is the first Z-Wave product in Home Assistant's Connect line. The "2" refers to the second-generation technology platform, not a previous ZWA-1 model.</p>
<p><strong>Prerequisites:</strong> Ugreen NAS with Docker installed, SSH access to your NAS, Home Assistant Connect ZWA-2 USB stick, and a USB extension cable (recommended for better signal).</p>
<p><strong>Connect the hardware:</strong> Plug the ZWA-2 into the NAS. A USB extension cable helps — it positions the stick away from interference.</p>
<p><strong>Identify the device path:</strong> SSH into your NAS and locate your Z-Wave device with <code>ls /dev/serial/by-id/</code>. You should see something like <code>usb-Nabu_Casa_ZWA-XXXXXXXXXX-if00</code>. This is your device identifier.</p>
<p><strong>Create persistent storage:</strong> Run <code>mkdir -p /volume1/docker/zwave-store</code>. This directory stores your Z-Wave network config, device database, and security keys. Without it, you lose everything on container restart.</p>
<p><strong>Run the Docker container:</strong></p>
<pre><code class="hljs language-bash">docker run --rm -it \
  -p <span class="hljs-number">8091</span><span class="hljs-punctuation">:</span><span class="hljs-number">8091</span> -p <span class="hljs-number">3000</span><span class="hljs-punctuation">:</span><span class="hljs-number">3000</span> \
  --device=/dev/serial/by-id/usb-Nabu_Casa_ZWA-XXXXXXXXXX-if00<span class="hljs-punctuation">:</span>/dev/zwave \
  -v /volume1/docker/zwave-store<span class="hljs-punctuation">:</span>/usr/src/app/store \
  zwavejs/zwave-js-ui<span class="hljs-punctuation">:</span>latest
</code></pre><p>Port 8091 serves the Z-Wave JS UI web interface. Port 3000 runs the WebSocket server for Home Assistant. The device mapping connects the physical USB to <code>/dev/zwave</code> inside the container.</p>
<h2>Software Configuration</h2>
<p><strong>Access the web interface:</strong> Open <code>http://&lt;YOUR_NAS_IP&gt;:8091/</code> in your browser. In Settings → Z-Wave, set Serial Port to <code>/dev/zwave</code> and click Save.</p>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p><strong>Generate security keys:</strong> Navigate to Settings → Z-Wave → Security Keys and click Generate for each key type (S0, S2 Access Control, S2 Authenticated, S2 Unauthenticated). Save these keys securely—you'll need them to re-pair devices if you rebuild the container. Without these keys, secure devices cannot be added to your network.</p>
</div>
<p><strong>Set RF region:</strong> Go to Settings → Z-Wave → RF Manager and set RF Region to your location (USA, EU, ANZ, etc.). Wrong region means communication failures and potential regulatory violations.</p>
<div class="markdown-alert markdown-alert-caution">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z"></path></svg>Caution</p>
<p>Setting the wrong RF region is not just a configuration error. Z-Wave devices on mismatched frequencies will pair but drop commands intermittently, making debugging extremely frustrating.</p>
</div>
<p><strong>Connect to Home Assistant:</strong> In Z-Wave JS UI, go to Settings → Home Assistant and enable WS Server. The WebSocket URL is <code>ws://&lt;YOUR_NAS_IP&gt;:3000</code>. In Home Assistant, go to Settings → Devices &amp; Services, add the Z-Wave JS integration, and enter that URL.</p>
<p><strong>Pair Z-Wave devices:</strong> In Z-Wave JS UI, click Control Panel → Add Node (or Include), then follow your device's pairing instructions (usually triple-press a button). Paired devices appear in Home Assistant automatically.</p>
<h2>Troubleshooting &amp; Maintenance</h2>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Without backups, you'll need to re-pair all devices if the container is lost.</p>
</div>
<p><strong>Backup strategy:</strong> Back up security keys (Settings → Z-Wave → Security Keys, export as JSON), the entire <code>/volume1/docker/zwave-store</code> directory, and your Home Assistant config. Automate backups with: <code>tar -czf zwave-backup-$(date +%Y%m%d).tar.gz /volume1/docker/zwave-store</code></p>
<p><strong>Common issues:</strong></p>
<p>Device not found: Check if USB device is visible with <code>ls -la /dev/serial/by-id/</code> and review kernel logs with <code>dmesg | tail -n 50</code>.</p>
<p>Permission denied: The container needs access to the USB device. Ensure Docker has proper permissions or run with <code>--privileged</code> flag (less secure).</p>
<p>Controller offline: Verify serial port setting is <code>/dev/zwave</code>, check container logs with <code>docker logs &lt;container-id&gt;</code>, restart container, or check USB cable connection.</p>
<p>Home Assistant can't connect: Verify WebSocket server is enabled in Z-Wave JS UI, check firewall rules on NAS allow port 3000, confirm Home Assistant can reach NAS IP, and try <code>ws://</code> instead of <code>wss://</code> for local network.</p>
<p>Z-Wave JS UI bridges the ZWA-2 and Home Assistant via WebSocket, handling mesh network communication while Home Assistant handles automation.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Building a Modern Full-Stack Web Application - Part 2]]></title>
      <link>https://kahwee.com/2025/building-modern-full-stack-web-application-part-2/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/building-modern-full-stack-web-application-part-2/</guid>
      <pubDate>Wed, 10 Sep 2025 16:01:00 GMT</pubDate>
      <description><![CDATA[Deploying modern full-stack apps to production. Fly.io, Cloudflare CDN, CI/CD, and development workflows that scale.]]></description>
      <category>web-development</category>
      <category>react</category>
      <category>typescript</category>
      <category>software-engineering</category>
      <category>fly-io</category>
      <category>cloudflare</category>
      <content:encoded><![CDATA[<p>This post covers deployment and production workflows. <a href="/2025/building-modern-full-stack-web-application-part-1/">Part 1</a> covered the tech stack and architecture.</p>
<h2>Hosting &amp; Deployment</h2>
<h3>Infrastructure</h3>
<p><strong><a href="/tags/fly-io/">Fly.io</a></strong> hosts the application. It scales to zero when idle and runs database migrations during deployment.</p>
<p><strong><a href="/tags/cloudflare/">Cloudflare</a></strong> handles CDN, DDoS protection, and SSL.</p>
<p><strong>Neon</strong> hosts PostgreSQL with serverless auto-scaling, database branching for dev/staging, point-in-time recovery, and connection pooling.</p>
<h3>Docker Multi-Stage Builds</h3>
<p>The application uses multi-stage builds for optimization:</p>
<pre><code class="hljs language-dockerfile">FROM node<span class="hljs-punctuation">:</span><span class="hljs-number">24</span>-alpine AS base
WORKDIR /app

FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --only=production

FROM base AS build
COPY . .
RUN npm run build

FROM base AS runtime
COPY --from=build /app/build ./build
COPY --from=deps /app/node_modules ./node_modules
EXPOSE <span class="hljs-number">3000</span>
CMD <span class="hljs-punctuation">[</span><span class="hljs-string">"npm"</span><span class="hljs-punctuation">,</span> <span class="hljs-string">"start"</span><span class="hljs-punctuation">]</span>
</code></pre><h2>Development Workflow</h2>
<pre><code class="hljs language-bash"># Development
npm run dev        # Start development server
npm run test       # Run tests
npm run typecheck  # Type checking

# Database
npm run db<span class="hljs-punctuation">:</span>studio  # Visual database management
npm run db<span class="hljs-punctuation">:</span>seed    # Populate with sample data
npm run db<span class="hljs-punctuation">:</span>migrate # Apply schema changes
</code></pre><h3>Code Quality Standards</h3>
<p>The project uses ESLint with React and TypeScript rules, Prettier for formatting, strict TypeScript with <code>noUncheckedIndexedAccess</code>, Husky for git hooks, Vitest for unit tests, and Playwright for E2E testing.</p>
<h3>Database Development with Drizzle Kit</h3>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>Use <code>db:push</code> for quick development iteration, but always use <code>db:generate</code> and <code>db:migrate</code> for production changes to maintain migration history.</p>
</div>
<p>Schema changes follow a structured approach:</p>
<pre><code class="hljs language-bash"># <span class="hljs-number">1.</span> Edit app/db/schema.ts
# <span class="hljs-number">2.</span> Generate migration
npm run db<span class="hljs-punctuation">:</span>generate

# <span class="hljs-number">3.</span> Apply migration
npm run db<span class="hljs-punctuation">:</span>migrate

# Development workflow
npm run db<span class="hljs-punctuation">:</span>push   # Quick schema sync (dev only)
npm run db<span class="hljs-punctuation">:</span>studio # Visual database UI
npm run db<span class="hljs-punctuation">:</span>reset  # Clean database reset
</code></pre><h2>Performance</h2>
<p>Vite delivers sub-second HMR, tree shaking, route-based code splitting, and image compression.</p>
<p>Drizzle ORM caches query plans with prepared statements and pools connections. Type-safe migrations prevent runtime errors.</p>
<p>Catalyst UI gave us high-quality components without overhead. Fly.io reduced operational complexity to near zero.</p>
<div class="markdown-alert markdown-alert-caution">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z"></path></svg>Caution</p>
<p>Run <code>db:generate</code> and <code>db:migrate</code> before deploying, never <code>db:push</code>. Using <code>db:push</code> in production skips migration history and makes rollbacks impossible.</p>
</div>
<p>The deployment pipeline runs tests on every push, checks types, runs migrations during deploy, and rolls back on failure. React Router v7, Drizzle ORM, better-auth, and Fly.io together make a stack I'd pick again.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Building a Modern Full-Stack Web Application - Part 1]]></title>
      <link>https://kahwee.com/2025/building-modern-full-stack-web-application-part-1/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/building-modern-full-stack-web-application-part-1/</guid>
      <pubDate>Wed, 10 Sep 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Choosing the right tech stack for modern full-stack apps. React Router v7, TypeScript, PostgreSQL, and architecture decisions that matter.]]></description>
      <category>web-development</category>
      <category>react</category>
      <category>typescript</category>
      <category>software-engineering</category>
      <content:encoded><![CDATA[<p>LLMs made it cheap to prototype frameworks and libraries. I used that to build a full-stack web application with modern TypeScript tooling. This is Part 1: tech stack and architecture. <a href="/2025/building-modern-full-stack-web-application-part-2/">Part 2</a> covers deployment and workflow.</p>
<h2>Tech Stack</h2>
<p>The foundation is React Router v7 with TypeScript, PostgreSQL for data, and modern UI libraries:</p>
<p><strong>Frontend</strong>: React Router v7 with React 19.1.1, TypeScript 5.9.2 for type safety</p>
<p><strong>UI &amp; Styling</strong>: Tailwind CSS v4.1.13, Catalyst UI components, Headless UI, Heroicons, Motion for animations</p>
<p><strong>Backend &amp; Data</strong>: Node.js v24+, PostgreSQL with Neon serverless, Drizzle ORM v0.44.5 for type-safe queries</p>
<p><strong>Authentication</strong>: better-auth v1.3.9 with WebAuthn, OAuth, and session management. Zod v4.1.5 for validation</p>
<p><strong>Dev Tools</strong>: Vite v7.1.5, ESLint, Prettier, Vitest, Playwright, Storybook</p>
<h2>Architecture Decisions</h2>
<h3>Type-Safe Data Layer</h3>
<p>Using Drizzle ORM provides full type safety across the entire data layer:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// Type-safe queries with full IntelliSense</span>
<span class="hljs-keyword">const</span> users = <span class="hljs-keyword">await</span> db.<span class="hljs-property">query</span>.<span class="hljs-property">users</span>.<span class="hljs-title function_">findMany</span>({
  <span class="hljs-attr">where</span>: <span class="hljs-title function_">eq</span>(users.<span class="hljs-property">isActive</span>, <span class="hljs-literal">true</span>),
  <span class="hljs-attr">with</span>: {
    <span class="hljs-attr">profile</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">settings</span>: <span class="hljs-literal">true</span>,
  },
});

<span class="hljs-comment">// Prepared statements for performance</span>
<span class="hljs-keyword">const</span> getUserById = db
  .<span class="hljs-title function_">select</span>()
  .<span class="hljs-title function_">from</span>(users)
  .<span class="hljs-title function_">where</span>(<span class="hljs-title function_">eq</span>(users.<span class="hljs-property">id</span>, sql.<span class="hljs-title function_">placeholder</span>(<span class="hljs-string">"id"</span>)))
  .<span class="hljs-title function_">prepare</span>();
</code></pre><p>The database schema uses normalized tables with foreign keys, JSON columns for flexible data, and indexes on frequently queried columns.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Drizzle ORM's <code>sql.placeholder()</code> prepared statements require exact type matching. A string where the schema expects an integer will fail silently at runtime, not at compile time.</p>
</div>
<h3>Authentication with Multiple Methods</h3>
<p>The authentication layer uses better-auth: email/password, WebAuthn/passkeys, OAuth, and HTTP-only cookie sessions.</p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> auth = <span class="hljs-title function_">betterAuth</span>({
  <span class="hljs-attr">database</span>: <span class="hljs-title function_">drizzleAdapter</span>(db, {
    <span class="hljs-attr">provider</span>: <span class="hljs-string">"pg"</span>,
  }),
  <span class="hljs-attr">emailAndPassword</span>: {
    <span class="hljs-attr">enabled</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">requireEmailVerification</span>: <span class="hljs-literal">true</span>,
  },
  <span class="hljs-attr">socialProviders</span>: {
    <span class="hljs-attr">github</span>: {
      <span class="hljs-attr">clientId</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">GITHUB_CLIENT_ID</span>!,
      <span class="hljs-attr">clientSecret</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">GITHUB_CLIENT_SECRET</span>!,
    },
  },
  <span class="hljs-attr">passkey</span>: {
    <span class="hljs-attr">enabled</span>: <span class="hljs-literal">true</span>,
  },
});
</code></pre><h3>API Architecture with React Router v7 Typegen</h3>
<p>React Router v7's typegen automatically generates route-specific types based on your file structure:</p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">Route</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'./+types/app.dashboard._index'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-attr">meta</span>: <span class="hljs-title class_">Route</span>.<span class="hljs-property">MetaFunction</span> = <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-keyword">return</span> [
    { <span class="hljs-attr">title</span>: <span class="hljs-string">'Dashboard - My App'</span> },
    { <span class="hljs-attr">name</span>: <span class="hljs-string">'description'</span>, <span class="hljs-attr">content</span>: <span class="hljs-string">'Application dashboard overview'</span> },
  ];
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ params, request }: <span class="hljs-title class_">Route</span>.<span class="hljs-title class_">LoaderArgs</span></span>) {
  <span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> <span class="hljs-title function_">requireAuth</span>(request);
  <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> <span class="hljs-title function_">getData</span>(params.<span class="hljs-property">id</span>);
  <span class="hljs-keyword">return</span> { data, user };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Component</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> { data, user } = useLoaderData&lt;<span class="hljs-keyword">typeof</span> loader&gt;();
  <span class="hljs-keyword">return</span> <span class="language-xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>...<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span>;
}
</code></pre><div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>This pattern eliminates separate API routes. Type safety extends from loader arguments to meta functions to client-side data consumption.</p>
</div>
<h2>Why This Stack Works</h2>
<p>Vite gives fast HMR and automatic code splitting. Drizzle ORM handles prepared statements and connection pooling. Better-auth removes authentication boilerplate. React Router v7's typegen catches bugs at compile time instead of runtime.</p>
<p>TypeScript caught dozens of potential runtime errors during development. That alone justified the stack choice.</p>
<p>Continue to <a href="/2025/building-modern-full-stack-web-application-part-2/">Part 2</a> for deployment, workflows, and performance.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Fixing Cloudflare 525 Errors When Pointing a Domain to Fly.io]]></title>
      <link>https://kahwee.com/2025/cloudflare-flyio-dns-ssl-troubleshooting/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/cloudflare-flyio-dns-ssl-troubleshooting/</guid>
      <pubDate>Thu, 04 Sep 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Fix Cloudflare 525 errors when pointing domains to Fly.io. See root cause, solution, and mental model for DNS/SSL troubleshooting.]]></description>
      <category>cloudflare</category>
      <category>web-development</category>
      <content:encoded><![CDATA[<p>My Fly.io app fronted by Cloudflare kept returning <strong>Error 525 (SSL handshake failed)</strong>. Here is the breakdown.</p>
<h2>Two TLS Hops, One Failure Point</h2>
<p>When Cloudflare proxying is enabled (orange cloud), two TLS connections exist: browser to Cloudflare edge, then Cloudflare edge to Fly.io origin. Fly.io must present a valid certificate for the custom domain. If the origin cert is missing or wrong, Cloudflare cannot complete the handshake. You get 525.</p>
<p>The symptom: <code>525 SSL handshake failed</code> only when Cloudflare proxy was enabled. With proxy off (grey cloud / DNS only), the site showed certificate mismatch errors instead.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p><strong>Root cause:</strong> DNS records at Cloudflare were inconsistent (stale A/AAAA or CNAME remnants). Fly.io manages cert issuance itself through ACME (Let's Encrypt). Dirty DNS prevented ACME validation, which meant no valid origin cert, which meant Cloudflare's handshake failed.</p>
</div>
<p><strong>Mental model:</strong></p>
<pre><code class="hljs">Browser --TLS--&gt; Cloudflare --TLS--&gt; Fly.io
                      ^
            Needs valid cert at origin
</code></pre><p>If Cloudflare cannot finish that second TLS hop, you get 525.</p>
<h2>The Fix</h2>
<p><strong>Clean your DNS.</strong> Remove every unrelated or legacy record for the apex and www. Delete old A, AAAA, and CNAME records not belonging to the Fly.io deployment. ACME validation and routing depend on unambiguous records.</p>
<p><strong>Add correct AAAA records.</strong> Fly.io provides an IPv6 that routes to the app:</p>
<pre><code class="hljs">example.com          AAAA   &lt;fly-io-ipv6-address&gt;  (Proxied)
www.example.com      AAAA   &lt;fly-io-ipv6-address&gt;  (Proxied)
</code></pre><p>No A record needed if Fly.io only gave IPv6. Cloudflare terminates at edge (dual stack) then connects over IPv6 downstream.</p>
<p><strong>Preserve ACME records.</strong> Leave existing <code>_acme-challenge</code> CNAMEs intact. These allow Let's Encrypt to validate via DNS-01.</p>
<p><strong>Wait for propagation.</strong> TTL and resolver caches need a few minutes. Verify with <code>dig</code> from several public resolvers.</p>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p><strong>Let Fly.io re-issue certificates:</strong> Once DNS points correctly, Fly.io's dashboard shows domain status as Verified with RSA + ECDSA issued. Set Cloudflare SSL Mode to <strong>Full (Strict)</strong>, not Flexible. Strict ensures Cloudflare validates the origin cert chain instead of ignoring it.</p>
</div>
<h2>Three Things to Check</h2>
<p>I only needed to confirm three things:</p>
<ol>
<li>DNS returned just the Fly.io AAAA for apex + www</li>
<li>Fly.io marked the domain verified and showed issued certs</li>
<li>Cloudflare SSL mode was Full (Strict) with proxy on</li>
</ol>
<p>Everything else — manual probing, deep TLS inspection — was noise once those were correct.</p>
<p><strong>Why custom cert upload failed:</strong> Fly.io issues and renews its own certs via ACME. Cloudflare Origin Certificates only handle Cloudflare-to-origin trust and are not public-PKI certs. Uploading was never the path.</p>
<p><strong>Common pitfalls:</strong> Stale A/AAAA/CNAME records lingering. Mixing previous host IPs with Fly.io AAAA. Using "Flexible" SSL (masks the real problem). Trying to import certificates instead of letting automation run.</p>
<p><strong>Minimal checklist:</strong></p>
<ol>
<li>Add domain in Fly.io</li>
<li>Publish ONLY the Fly.io AAAA (and A if given) in Cloudflare; proxy on</li>
<li>Leave <code>_acme-challenge</code> CNAMEs untouched</li>
<li>Set SSL/TLS = Full (Strict)</li>
<li>Wait a few minutes; confirm Fly.io shows "Verified / Issued"</li>
</ol>
<p>A 525 error means a Cloudflare-to-origin TLS failure, and DNS cleanliness gates cert issuance. Let Fly.io handle the issuance, because fewer DNS records mean fewer surprises.</p>
<p>Proxy pointed at an origin without a valid cert because of messy DNS. Clean DNS, correct AAAA only, wait for Fly.io issuance, Full (Strict). If you see 525: check DNS, then Fly.io cert status, then Cloudflare mode. Stop when all three are green.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Migrating from Remix to React Router v7 - Part 2]]></title>
      <link>https://kahwee.com/2025/migrating-from-remix-to-react-router-v7-part-2/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/migrating-from-remix-to-react-router-v7-part-2/</guid>
      <pubDate>Tue, 26 Aug 2025 11:01:00 GMT</pubDate>
      <description><![CDATA[How to migrate from Remix to React Router v7: Step-by-step process, understanding typegen, and practical migration insights.]]></description>
      <category>technology</category>
      <category>web-development</category>
      <category>react</category>
      <content:encoded><![CDATA[<p>This is Part 2 covering the step-by-step migration process. <a href="/2025/migrating-from-remix-to-react-router-v7-part-1/">Part 1</a> covered why I migrated and the main challenges.</p>
<h2>The Step-by-Step Process</h2>
<p><strong>Phase 1: Dependencies and Configuration (30 minutes)</strong></p>
<p>Replace all <code>@remix-run/*</code> packages with React Router equivalents in package.json. Change build/dev commands to use the <code>react-router</code> CLI. Simplify Vite configuration. Add <code>react-router.config.ts</code> for framework-specific settings.</p>
<p><strong>Phase 2: Import Refactoring (90 minutes)</strong></p>
<p>This phase ate the most time. Every file importing from <code>@remix-run/*</code> needed updates:</p>
<ul>
<li>Route files (23 files): Updated loader/action imports and useLoaderData calls</li>
<li>Component files (8 files): Changed Link and Form imports</li>
<li>Entry files (2 files): Updated server/client entry points</li>
<li>Utility files (7 files): Modified auth and theme utilities</li>
</ul>
<p>Start with entry points, then routes, then components. Fix TypeScript errors as they appear.</p>
<p><strong>Phase 3: Configuration Fine-tuning (30 minutes)</strong></p>
<p>Updated TypeScript config to use React Router types. Converted splat routes from <code>api.auth.$.ts</code> to <code>api.auth.$rest.tsx</code>. Added the <code>react-router typegen</code> command to dev scripts.</p>
<h2>Typegen Changes Everything</h2>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p><code>react-router typegen</code> automatically generates route-specific TypeScript types, eliminating manual interface definitions and preventing type drift.</p>
</div>
<p>This feature didn't exist in Remix. It's a React Router v7 innovation.</p>
<p>The <code>typegen</code> command generates a <code>+types/&lt;route-file&gt;.d.ts</code> for each route. You get automatic type inference for loader data, route-specific parameter typing, action and loader return type validation, and component prop type safety — all from your actual route implementation.</p>
<p>Instead of manually defining <code>LoaderData</code> interfaces for every route, typegen infers them from your loaders:</p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// New React Router v7 pattern - types inferred automatically</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">loader</span>(<span class="hljs-params">{ request }: <span class="hljs-title class_">LoaderFunctionArgs</span></span>) {
  <span class="hljs-comment">// Return whatever you want, typegen handles the interface</span>
  <span class="hljs-keyword">return</span> { <span class="hljs-attr">recipes</span>: <span class="hljs-keyword">await</span> <span class="hljs-title function_">getRecipes</span>(), <span class="hljs-attr">user</span>: <span class="hljs-keyword">await</span> <span class="hljs-title function_">getUser</span>(request) };
}

<span class="hljs-comment">// Component gets proper typing automatically</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">RecipeList</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> { recipes, user } = useLoaderData&lt;<span class="hljs-keyword">typeof</span> loader&gt;();
  <span class="hljs-comment">// recipes and user are fully typed without manual interfaces</span>
}
</code></pre><p>This fixes the interface drift problem. With manual interfaces, you change a loader to return additional data but forget to update the interface. TypeScript doesn't complain — it silently ignores the new properties.</p>
<p><code>useLoaderData&lt;typeof loader&gt;</code> eliminates this. The type reflects your actual loader implementation. When you refactor a loader's return structure, every component using that data updates automatically.</p>
<h2>What Happened to Remix?</h2>
<div class="markdown-alert markdown-alert-note">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"></path></svg>Note</p>
<p>Remix v3 is a complete rewrite with no React dependencies—built on a Preact fork for "AI-first development." React Router v7 is now the clear successor for production React applications.</p>
</div>
<p>Ryan Florence and Michael Jackson handed React Router v7 to an open governance committee while they focus on Remix v3. Remix v3 isn't an iteration — it's a complete rewrite with no React dependencies. They're building on a fork of Preact for "AI-first development" and "AI driven user interfaces."</p>
<p>React Router v7 is the successor for production React apps that want the Remix experience.</p>
<h2>Worth the Switch?</h2>
<p>React Router v7 is actively developed. Remix v2 is in maintenance mode. The simplified dependency tree, better TypeScript integration, and improved build system pay off immediately.</p>
<p>For teams on Remix v2 with future flags enabled, the migration is straightforward. AI assistance was critical for batch import updates across 40+ files. What could have been a full day of manual work became two focused sessions.</p>
<p>The migration runs smoothly on React Router v7 now. Same power as Remix, simpler tooling, stronger types.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Migrating from Remix to React Router v7 - Part 1]]></title>
      <link>https://kahwee.com/2025/migrating-from-remix-to-react-router-v7-part-1/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/migrating-from-remix-to-react-router-v7-part-1/</guid>
      <pubDate>Tue, 26 Aug 2025 11:00:00 GMT</pubDate>
      <description><![CDATA[Why migrate from Remix to React Router v7: Understand the rationale, challenges, and what actually changed. Real insights from a full migration.]]></description>
      <category>technology</category>
      <category>web-development</category>
      <category>react</category>
      <content:encoded><![CDATA[<p>I migrated a full-stack React application from Remix to React Router v7 framework mode over a weekend. Two days, surprisingly smooth. This is Part 1 covering why I switched and what tripped me up. <a href="/2025/migrating-from-remix-to-react-router-v7-part-2/">Part 2</a> covers the step-by-step process.</p>
<h2>Why React Router v7?</h2>
<p>React Router v7 is Remix v3 renamed. Ryan Florence and Michael Jackson merged the projects because "Remix v2 had become such a thin wrapper around React Router that an artificial separation developed between the two projects."</p>
<p>The payoff is immediate. Instead of juggling <code>@remix-run/node</code>, <code>@remix-run/react</code>, <code>@remix-run/serve</code>, and others, everything consolidates into <code>react-router</code>. My dependencies dropped from 16 to 3. The <code>react-router typegen</code> command eliminates manual type annotations in loader functions. The Vite-based build system is cleaner than Remix's custom setup.</p>
<h2>TypeScript Was the Hard Part</h2>
<p>The API stayed compatible. <strong>TypeScript did not.</strong></p>
<p>The git log tells the story — multiple commits dedicated to resolving type issues:</p>
<ul>
<li><strong>LoaderFunctionArgs and useLoaderData typing</strong>: Every route needed manual type updates</li>
<li><strong>Route module type safety</strong>: New type patterns required learning React Router v7's approach</li>
<li><strong>Interface mismatches</strong>: Database query results needed type alignment with component expectations</li>
<li><strong>Case-sensitivity bugs</strong>: Database queries failed due to case-sensitive matching that worked in Remix</li>
</ul>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>Multiple commits were dedicated to resolving TypeScript errors, indicating hours of type debugging across the migration. React Router v7's type system is more strict than Remix v2, which is ultimately beneficial but creates friction during migration.</p>
</div>
<h2>What Actually Changed</h2>
<p>The migration touched <strong>40 files</strong> with <strong>327 insertions and 918 deletions</strong> — a net reduction of 591 lines. The main changes: package dependencies (replacing all <code>@remix-run/*</code> packages), build scripts (switched to <code>react-router</code> CLI), and Vite configuration.</p>
<p>The Vite config transformation was dramatic:</p>
<pre><code class="hljs language-diff"><span class="hljs-deletion">- import { vitePlugin as remix } from '@remix-run/dev';</span>
<span class="hljs-deletion">- // Complex remix configuration with future flags</span>
<span class="hljs-deletion">- remix({</span>
<span class="hljs-deletion">-   future: {</span>
<span class="hljs-deletion">-     v3_fetcherPersist: true,</span>
<span class="hljs-deletion">-     v3_relativeSplatPath: true,</span>
<span class="hljs-deletion">-     v3_throwAbortReason: true,</span>
<span class="hljs-deletion">-     v3_singleFetch: true,</span>
<span class="hljs-deletion">-     v3_lazyRouteDiscovery: true,</span>
<span class="hljs-deletion">-   }</span>
<span class="hljs-deletion">- })</span>
<span class="hljs-addition">+ import { reactRouter } from '@react-router/dev/vite';</span>
<span class="hljs-addition">+ // Simple, clean configuration</span>
<span class="hljs-addition">+ plugins: [reactRouter(), tsconfigPaths(), tailwindcss()]</span>
</code></pre><p>Every route and component needed import updates:</p>
<pre><code class="hljs language-diff"><span class="hljs-deletion">- import type { LoaderFunctionArgs } from '@remix-run/node';</span>
<span class="hljs-deletion">- import { useLoaderData } from '@remix-run/react';</span>
<span class="hljs-addition">+ import type { LoaderFunctionArgs } from 'react-router';</span>
<span class="hljs-addition">+ import { useLoaderData } from 'react-router';</span>
</code></pre><p>Loaders, actions, and components work identically. The migration was mostly changing imports and build configuration.</p>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>Route files with <code>.ts</code> extensions need to become <code>.tsx</code> for React Router v7 to recognize them.</p>
</div>
<p>The entry.server.tsx got simpler — React Router v7 removed the bot/browser request splitting logic. Bundle size dropped ~30% and build times improved with the unified package structure.</p>
<p>Continue to <a href="/2025/migrating-from-remix-to-react-router-v7-part-2/">Part 2</a> for the detailed step-by-step migration process and React Router's typegen feature.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[The Cost of Switching from Next.js to Remix]]></title>
      <link>https://kahwee.com/2025/cost-of-switching-from-nextjs-to-remix/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/cost-of-switching-from-nextjs-to-remix/</guid>
      <pubDate>Sun, 24 Aug 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Next.js to Remix: Discover what I learned about "use client" boundaries after 3 months. Why this matters more than you think.]]></description>
      <category>web-development</category>
      <category>react</category>
      <category>performance</category>
      <content:encoded><![CDATA[<h2>The Learning</h2>
<p>Three months ago, I ditched Next.js for Remix. Remix felt like regular web development without the mental overhead of React Server Components and <code>"use client"</code> boundaries.</p>
<p>I didn't expect to learn why those boundaries exist.</p>
<h2>The "use client" Boundary I Didn't Understand</h2>
<p>In Next.js, <code>"use client"</code> felt like an annoying annotation. Need a click handler? Add <code>"use client"</code>. Want useState? Add <code>"use client"</code>. Access localStorage? Same.</p>
<p>I thought it was just ceremony, but I was wrong.</p>
<p>That directive creates a hard boundary in your component tree. Everything above it runs on the server during rendering. Everything below it ships to the browser as JavaScript. Server and client code have different capabilities and constraints.</p>
<p>On the server side, you have database queries, file system access, and environment variables but no user interaction.<br />On the client side, you have DOM APIs, user events, and localStorage but no direct database access.</p>
<p>The boundary forces you to think about this split instead of accidentally mixing concerns.</p>
<h2>The Bundle Creep Problem</h2>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p><code>"use client"</code> is contagious. Mark one parent component as client-side, and every child component becomes client-side too, even if they don't need browser APIs.</p>
</div>
<p>I added <code>"use client"</code> to a layout component for a mobile menu toggle. My entire page — header, sidebar, content, footer — started shipping to the browser as JavaScript instead of rendering on the server.</p>
<p>Some Next.js teams ban <code>"use client"</code> except in specific directories. When someone needs interactivity, they create a client component in a designated place like <code>components/client/</code>. This prevents one directive from dragging an entire page into the client bundle.</p>
<h2>How Remix Handles the Server/Client Split</h2>
<p>Remix doesn't need <code>"use client"</code> because it enforces the boundary architecturally.</p>
<p>Server code lives in <code>loader</code> and <code>action</code> functions. These run on the server and can access databases, file systems, environment variables. They return plain data.</p>
<p>Client code lives in React components. These receive data as props from loaders and can use all browser APIs — useState, event handlers, localStorage. You can't accidentally put a database query in a component because loaders and actions are the only places with server context.</p>
<h2>What I Actually Miss</h2>
<p><strong>Automatic tree shaking</strong>: Next.js strips browser-only dependencies from the server bundle when it sees a server component. In Remix, you keep server and client imports separate through discipline.</p>
<p><strong>Component-level boundaries</strong>: With RSC, a server component fetches data, renders UI, and nests client components inside for interactivity. The boundary is fine-grained.</p>
<p><strong>Bundle optimization</strong>: Server components don't add to your client bundle at all.</p>
<p>In Remix, the entire component tree ships to the client, even parts that don't need interactivity. You optimize by discipline, not by framework.</p>
<h2>The Real Tradeoff</h2>
<div class="markdown-alert markdown-alert-important">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Important</p>
<p>The server/client boundary is the most important architectural decision in modern React apps.</p>
</div>
<p>Next.js makes it explicit with <code>"use client"</code> but easy to mess up. One misplaced directive and your bundle explodes. Remix makes it implicit with loaders/actions but requires discipline. You can accidentally import server-only code in components if you're not careful.</p>
<hr />
<p><strong>Related posts:</strong></p>
<ul>
<li><a href="/2025/migrating-from-remix-to-react-router-v7/">Migrating from Remix to React Router v7</a> - The next chapter: leaving Remix for React Router</li>
<li><a href="/2025/building-modern-full-stack-web-application/">Building a Modern Full-Stack Web Application</a> - Full-stack patterns with React Router v7</li>
<li><a href="/2025/when-rebuilding-is-better-than-refactoring/">When Rebuilding is Better than Refactoring</a> - Why I rebuilt instead of refactored</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Vercel & Remix]]></title>
      <link>https://kahwee.com/2025/vercel-promise-vs-reality-emfile-remix/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/vercel-promise-vs-reality-emfile-remix/</guid>
      <pubDate>Sat, 23 Aug 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Vercel & Remix issues: EMFILE and memory ceilings on hobby plan. Discover patterns and causes of recurring Remix build failures.]]></description>
      <category>technology</category>
      <category>web-development</category>
      <category>performance</category>
      <category>developer-tool</category>
      <content:encoded><![CDATA[<p>Vercel's free hobby plan looks convenient: push to deploy, global edge, previews. After a week of testing a Remix app, file handle limits became the actual bottleneck.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Persistent EMFILE errors (too many open files) appeared only in the Vercel environment, never locally:</p>
</div>
<pre><code class="hljs">Error<span class="hljs-punctuation">:</span> EMFILE<span class="hljs-punctuation">:</span> too many open files<span class="hljs-punctuation">,</span> open '/var/task/node_modules/nanostores/index.js'
at Object.openSync (node<span class="hljs-punctuation">:</span>fs<span class="hljs-punctuation">:</span><span class="hljs-number">562</span><span class="hljs-punctuation">:</span><span class="hljs-number">18</span>)
at readFileSync (node<span class="hljs-punctuation">:</span>fs<span class="hljs-punctuation">:</span><span class="hljs-number">446</span><span class="hljs-punctuation">:</span><span class="hljs-number">35</span>)
at getSourceSync (node<span class="hljs-punctuation">:</span>internal/modules/esm/load<span class="hljs-punctuation">:</span><span class="hljs-number">66</span><span class="hljs-punctuation">:</span><span class="hljs-number">14</span>)
at getSource (node<span class="hljs-punctuation">:</span>internal/modules/esm/translators<span class="hljs-punctuation">:</span><span class="hljs-number">68</span><span class="hljs-punctuation">:</span><span class="hljs-number">10</span>)
at createCJSModuleWrap (node<span class="hljs-punctuation">:</span>internal/modules/esm/translators<span class="hljs-punctuation">:</span><span class="hljs-number">185</span><span class="hljs-punctuation">:</span><span class="hljs-number">32</span>)
at ModuleLoader.commonjsStrategy (node<span class="hljs-punctuation">:</span>internal/modules/esm/translators<span class="hljs-punctuation">:</span><span class="hljs-number">275</span><span class="hljs-punctuation">:</span><span class="hljs-number">10</span>)
at #translate (node<span class="hljs-punctuation">:</span>internal/modules/esm/loader<span class="hljs-punctuation">:</span><span class="hljs-number">534</span><span class="hljs-punctuation">:</span><span class="hljs-number">12</span>)
at ModuleLoader.loadAndTranslate (node<span class="hljs-punctuation">:</span>internal/modules/esm/loader<span class="hljs-punctuation">:</span><span class="hljs-number">581</span><span class="hljs-punctuation">:</span><span class="hljs-number">27</span>)
Node.js process exited with exit status<span class="hljs-punctuation">:</span> <span class="hljs-number">1.</span>
</code></pre><p>Vercel helpfully adds:</p>
<blockquote>
<p>The logs above can help with debugging the issue.</p>
</blockquote>
<p>That note does not help since you get no visibility into the per-process file descriptor limit.</p>
<p>Remix users hit this more than most. Vercel's serverless runtime is more restrictive than local dev environments, so file handle limits surface only during deployment.</p>
<h2>What the Community Reports</h2>
<p><strong>Reddit threads</strong>: Recurring EMFILE on deploy, often with lucide-react and other icon sets.</p>
<p><strong>GitHub issues</strong>: Remix + Vite + large icon libraries (phosphor, heroicons, lucide) trigger failures. Replacing barrel imports with direct paths reduces frequency.</p>
<p><strong>Consensus</strong>: This is a platform constraint. Local dev rarely reproduces it. Import patterns influence open-file churn.</p>
<p>The community workarounds:</p>
<ul>
<li>Optimizing build-time concurrency</li>
<li>Refactoring dynamic imports from icon libraries</li>
<li>Updating dependencies and build tooling</li>
<li>Switching from barrel to direct imports</li>
</ul>
<p>These are mitigations, not fixes. They trade code clarity for staying within an unknown cap.</p>
<h2>Routing Convention Collision</h2>
<div class="markdown-alert markdown-alert-caution">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z"></path></svg>Caution</p>
<p>Beyond file handle limits, Vercel's filesystem-based routing clashes with Remix conventions.</p>
</div>
<p>Remix uses parentheses in filenames for layout routes — like <code>app/routes/(dashboard).profile.tsx</code> to create nested layouts without affecting the URL structure.</p>
<p>Vercel reserves parentheses for its own routing logic. Deploy a Remix app with parenthesized route files and the build process throws errors until you rename them. Your intended routing structure breaks.</p>
<p><strong>Why this happens</strong>: Vercel generates serverless functions from your filesystem structure. Each route file becomes a separate endpoint. The platform reserves <code>[]</code> for dynamic segments and <code>()</code> for route groups. Remix uses parentheses for layout composition, not URL generation. The two conventions collide.</p>
<p><strong>The workaround tax</strong>: You refactor route organization to avoid parentheses. This means losing the clean layout composition that makes Remix routing elegant. Instead of <code>(dashboard)</code> layouts, you use nested folder structures that don't map as cleanly to your component hierarchy.</p>
<h2>So what next?</h2>
<p>I am considering Fly.io as an alternative, so stay tuned.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[AI Overviews are cutting web traffic in half]]></title>
      <link>https://kahwee.com/2025/google-ai-overviews-cutting-web-traffic-in-half/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/google-ai-overviews-cutting-web-traffic-in-half/</guid>
      <pubDate>Wed, 23 Jul 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Google AI Overviews reduce web traffic by 50%. See research data and implications. Is traditional web traffic ending in 2025?]]></description>
      <category>ai</category>
      <category>google</category>
      <category>search</category>
      <category>web-traffic</category>
      <category>technology</category>
      <category>user-experience</category>
      <content:encoded><![CDATA[<h2>The Numbers</h2>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Google's AI Overviews cut click-through rates almost in half—from 15% to 8%. Just 1% of users click on cited sources within AI summaries.</p>
</div>
<p>Pew Research Center data, <a href="https://arstechnica.com/ai/2025/07/research-shows-google-ai-overviews-reduce-website-clicks-by-almost-half/" target="_blank" rel="noopener noreferrer nofollow">reported by Ars Technica</a>, shows Google's AI Overviews are halving website traffic. When AI-generated summaries appear at the top of search results, click-through rates drop from 15% to 8%.</p>
<p>Only 1% of users click on sources cited within the AI Overviews. Wikipedia, YouTube, and Reddit are the most frequently referenced. About 1 in 5 Google searches now display these AI Overviews, especially for longer, question-based queries.</p>
<p>Google claims AI features drive engagement and create new opportunities for websites. The data says the opposite. Users read the AI summary and stop searching. They leave with whatever the model gave them — including errors, since generative AI still hallucinates.</p>
<h2>I Stopped Clicking Too</h2>
<p>I use Dia, a browser with a built-in LLM, and I barely touch Google anymore. Information gets synthesized directly, and I rarely visit the underlying websites.</p>
<p>Getting contextualized answers without clicking through multiple sites changed how I consume information entirely. Are we watching the "dead internet theory" arrive in practice? When AI systems synthesize and present information without sending users to original sources, the web's click-through economy breaks down.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Anthropic Wins Major Fair Use Victory for AI Training]]></title>
      <link>https://kahwee.com/2025/anthropic-wins-major-fair-use-victory-but-still-liable-for-pirated-books/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/anthropic-wins-major-fair-use-victory-but-still-liable-for-pirated-books/</guid>
      <pubDate>Sat, 28 Jun 2025 17:00:00 GMT</pubDate>
      <description><![CDATA[A federal judge rules that training AI models on copyrighted books is fair use, but creating permanent libraries of pirated content crosses the line.]]></description>
      <category>ai</category>
      <category>copyright</category>
      <category>legal</category>
      <category>anthropic</category>
      <category>fair-use</category>
      <content:encoded><![CDATA[<p>Judge William Alsup just ruled that training AI on copyrighted books is fair use. But building permanent libraries of pirated books is not — even when used for training. The ruling in <em>Bartz v. Anthropic</em> splits the difference in a way that reshapes the legal ground for every AI company.</p>
<h2>From Piracy to Purchase</h2>
<p>Anthropic's data collection history tells the whole story. Founded by ex-OpenAI researchers in February 2021, the company started with pirated content.</p>
<p>Co-founder Ben Mann downloaded Books3 in early 2021 — 196,640 pirated books. By June 2021, he had downloaded at least five million books from Library Genesis. In July 2022, Anthropic added two million more from the Pirate Library Mirror. All of these sources were known to contain unauthorized copies.</p>
<p>Then Anthropic changed course entirely. In February 2024, they hired Tom Turvey, former head of partnerships for Google's book-scanning project. His mission: obtain "all the books in the world" while avoiding "legal/practice/business slog."</p>
<p>Turvey's team spent millions buying print books, often used. They stripped bindings, cut pages to size, scanned them into PDFs, and discarded the physical copies.</p>
<h2>The Ruling</h2>
<p>Judge Alsup's 32-page decision draws lines that will define AI copyright law.</p>
<h3>Fair Use</h3>
<p><strong>AI Training</strong>: The court called training LLMs on copyrighted books "spectacularly transformative." The judge compared it to how humans learn from reading — forcing people to pay "each time they read, each time they recall from memory, each time they later draw upon it when writing new things" would be unthinkable.</p>
<p><strong>Purchased-and-Scanned Books</strong>: Converting bought print books to digital for internal use is fair use, though on narrower grounds. The court treated it as format shifting.</p>
<h3>Not Fair Use</h3>
<p><strong>Pirated Central Library</strong>: Maintaining a permanent digital library of millions of pirated books is not fair use. The court stressed that Anthropic kept pirated copies even after deciding not to use them for training.</p>
<h2>What AI Companies Should Take Away</h2>
<ul>
<li><strong>Training on copyrighted material can be fair use</strong> when the process is transformative and doesn't produce infringing outputs</li>
<li><strong>Legitimate purchase and digitization</strong> for internal AI training is likely protected</li>
<li><strong>No special carveout for AI</strong>: The court stated plainly, "There is no carveout from the Copyright Act for AI companies"</li>
<li><strong>Piracy isn't excused by downstream fair use</strong></li>
<li><strong>Intent matters</strong>: The court weighed whether companies actively sought pirated content</li>
</ul>
<h2>What Happens Next</h2>
<p>Anthropic won on the training question but still faces a jury trial over damages for their pirated book library. Statutory damages for willful infringement could be steep.</p>
<p>OpenAI, Meta, and others used similar datasets — Books3 was part of Meta's LLaMA training data. They are watching this ruling closely.</p>
<h2>Unanswered Questions</h2>
<ul>
<li>What counts as "transformative" use across different AI contexts?</li>
<li>How does fair use apply to images, videos, or code?</li>
<li>What licensing models emerge to serve both creators and AI companies?</li>
</ul>
<p>Training on copyrighted content can be fair use, but building pirate libraries is not. The smartest approach is what Anthropic eventually adopted: invest in legitimate content acquisition, even when it is expensive.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[When Rebuilding is Better than Refactoring]]></title>
      <link>https://kahwee.com/2025/when-rebuilding-is-better-than-refactoring/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/when-rebuilding-is-better-than-refactoring/</guid>
      <pubDate>Sat, 21 Jun 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[When rebuilding beats refactoring: Why starting fresh often outperforms modifying legacy code. See Cursor vs VS Code and 2025 tooling evolution.]]></description>
      <category>software-engineering</category>
      <category>cursor</category>
      <category>ai</category>
      <category>typescript</category>
      <category>react</category>
      <category>web-development</category>
      <content:encoded><![CDATA[<p>We introduced a new entity to our data model and needed to update several pages. Instead of modifying each existing implementation, we rebuilt from scratch. The rebuild was faster and let us use modern patterns that would have been painful to retrofit.</p>
<h2>The Math Has Changed</h2>
<p>UI development costs less than it did five years ago. Better tooling, mature component libraries, and improved CSS make starting fresh viable more often.</p>
<p>Much of our codebase predates TypeScript. Some components still use Flow annotations. When facing large changes, scaffolding new components with current patterns beats incrementally modernizing legacy code.</p>
<p>Legacy code carries hidden costs: outdated dependencies, deprecated patterns, assumptions that no longer hold. Starting fresh means applying lessons learned without fighting existing architecture.</p>
<h2>Tooling Now</h2>
<p><a href="/tags/cursor/">Cursor</a> replaced VS Code as my default IDE. The AI integration feels natural — contextually aware tab completion that flows with development rather than interrupting it.</p>
<p>I tried <a href="https://warp.dev/" target="_blank" rel="noopener noreferrer nofollow">Warp.dev</a>. The interface felt clunky compared to traditional terminal environments. The promise is there, but the execution is not ready yet.</p>
<p>React's ecosystem has matured. TanStack Query solves problems we used to handle with complex custom implementations. These improvements make rebuilding cheaper than it used to be.</p>
<h2>When to Rebuild</h2>
<div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>Rebuild UI components and feature-specific code. Refactor critical systems and complex business logic. The threshold has shifted toward rebuilding for user interfaces.</p>
</div>
<p>The same tools that speed up development — AI assistance, improved frameworks, streamlined deployment — also compress time for documentation and reflection. Capture insights during development, not afterward.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[AI The New Backdoor Layoff]]></title>
      <link>https://kahwee.com/2025/ai-the-new-backdoor-layoff/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/ai-the-new-backdoor-layoff/</guid>
      <pubDate>Fri, 20 Jun 2025 17:00:00 GMT</pubDate>
      <description><![CDATA[AI as backdoor layoff: Companies use AI efficiency gains as PR cover for not hiring. Same result, better optics. See the pattern.]]></description>
      <category>ai</category>
      <category>corporate-strategy</category>
      <category>layoffs</category>
      <category>business</category>
      <category>technology</category>
      <category>workforce</category>
      <content:encoded><![CDATA[<p>Wall Street loves a good efficiency story. "AI-driven productivity gains" has become corporate speak for "we're not hiring anyone back."</p>
<h2>The New Playbook</h2>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>The market rewards "AI efficiency" framing with 5-8% stock gains, while traditional layoff announcements cause 8-12% drops. Same outcome, different perception.</p>
</div>
<p>Traditional layoff announcements tank stock prices, attract negative press, and crush morale. Companies learned this during the 2022-2023 tech downturn. Even mentioning workforce reductions triggered immediate market reactions. Many of these companies had overhired during 2021-2022, adding thousands of employees on pandemic-driven growth assumptions that collapsed.</p>
<p>The new approach skips the announcement. Instead of disclosing job cuts, companies highlight AI investments and productivity improvements. The message shifts from "we're laying off 2,000 people" to "our AI initiatives increased efficiency by 40%." There is no mention of overhiring — just technological evolution. The outcome is the same, but the optics are better.</p>
<p><strong>The numbers game:</strong> A traditional layoff announcement ("We're reducing headcount by 15%") drops stock 8-12%. An AI efficiency narrative ("AI has improved productivity, reducing our hiring needs") lifts stock 5-8%. The market rewards the second framing. The underlying economic impact is identical.</p>
<p><strong>How it sounds in practice:</strong> Earnings calls now feature "AI-enabled workforce optimization" instead of restructuring. The phrases have evolved: "Optimizing team structures through AI." "Reducing hiring needs through automation gains." "AI-driven efficiency improvements cutting operational costs." Each phrase signals reduced labor costs to investors without triggering a negative market response.</p>
<p><strong>The competitive equilibrium problem:</strong> Companies benchmark their workforce against competitors. If your competitor has 5,000 R&amp;D engineers, you need roughly the same to stay competitive. This creates an equilibrium where companies match hiring patterns. You can't announce "we're cutting engineering by 30%" without signaling weakness.</p>
<p>The AI efficiency story solves this. Instead of "we're falling behind in the talent war," companies claim "we're ahead in the efficiency war." The message: "Our 700 engineers with AI outperform competitor X's 1,000 engineers." Workforce reduction becomes competitive advantage.</p>
<p>When multiple companies in an industry adopt AI efficiency messaging simultaneously, it gives everyone permission to shrink teams. The entire industry can agree that "AI changes the game" instead of one company unilaterally disarming. This explains why AI efficiency announcements cluster within industries. Once one major player announces AI-driven workforce optimization, competitors follow to avoid looking behind.</p>
<h2>Corporate Case Studies</h2>
<p><strong>IBM - The Pioneer:</strong> IBM CEO Arvind Krishna made headlines in May 2023 when he <a href="https://www.bloomberg.com/news/articles/2023-05-01/ibm-to-pause-hiring-for-back-office-jobs-that-ai-could-kill" target="_blank" rel="noopener noreferrer nofollow">paused hiring for roles that could be replaced by AI</a>. He framed it as efficiency, not layoffs. Roughly 7,800 jobs would be "replaced by AI and automation over five years." His words: "I could easily see 30% of that getting replaced by AI and automation." No mass layoff announcement. No stock price drop.</p>
<p><strong>Meta - "Year of Efficiency":</strong> Meta overhired during the pandemic boom, then found itself overstaffed when growth assumptions collapsed. Zuckerberg reframed 2023 as the <a href="https://www.cnbc.com/2023/03/14/meta-layoffs-10000-more-workers-to-be-cut-in-restructuring.html" target="_blank" rel="noopener noreferrer nofollow">"Year of Efficiency"</a>, emphasizing AI automation that would reduce future hiring needs. The dual messaging was strategic: address immediate overstaffing with layoffs while setting expectations for permanently reduced headcount through AI. The market rewarded technological transformation over admission of hiring errors.</p>
<p><strong>Google - AI-First Reorganization:</strong> Google followed the same pattern. After pandemic-era hiring ballooned its workforce, the company faced overcapacity. Following ChatGPT's launch, Google <a href="https://www.theverge.com/2023/1/20/23563851/google-search-ai-chatbot-demo-chatgpt" target="_blank" rel="noopener noreferrer nofollow">declared a "code red"</a> and repositioned its 12,000-person reduction as an AI-first reorganization. Not a correction of overhiring. Earnings calls now emphasize AI productivity gains that justify "more disciplined" hiring practices. That's a euphemism for the lean workforce they should have maintained all along.</p>
<p><strong>Microsoft - Copilot as Workforce Multiplier:</strong> Microsoft positioned its AI strategy around "human-AI collaboration" rather than replacement. Yet Copilot products let single employees do tasks that previously required multiple people. Microsoft's earnings emphasize productivity multipliers and efficiency gains while quietly reducing hiring targets across divisions using Copilot.</p>
<h2>Why It Works</h2>
<p>Investors hear three things: innovation leadership (the company ships cutting-edge technology), cost optimization (operating expenses drop through automation), and future readiness (the workforce is becoming more productive). The AI framing delivers all three of these signals without the layoff stigma.</p>
<p>The competition shifts from "who has the most talented people" to "who has the most efficient AI-human collaboration." Companies reduce headcount while claiming advantage. They redirect hiring budgets toward AI infrastructure. They appear innovative rather than desperate. The result: industry-wide workforce shrinkage, all justified by AI advancement rather than economic pressure.</p>
<p>The practical result — fewer jobs available — gets buried in the positive narrative. In San Francisco's tech corridors, from <a href="https://yorksf.com/2026/mission-district-guide/" target="_blank" rel="noopener noreferrer nofollow">the Mission</a> to SoMa, the impact is tangible. While AI eliminates traditional jobs, <a href="https://justrealized.com/2025/uber-ai-data-labeling-gig-work/" target="_blank" rel="noopener noreferrer">gig platforms like Uber are creating new AI-related work opportunities for drivers</a>. The workforce transformation creates winners and losers.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Switching from Node to Bun - Part 2]]></title>
      <link>https://kahwee.com/2025/switching-from-node-to-bun-part-2/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/switching-from-node-to-bun-part-2/</guid>
      <pubDate>Tue, 29 Apr 2025 16:01:00 GMT</pubDate>
      <description><![CDATA[How to migrate from Node.js to Bun: Step-by-step process, challenges, results, and practical migration insights.]]></description>
      <category>technology</category>
      <category>performance</category>
      <category>web-development</category>
      <category>build-tool</category>
      <category>typescript</category>
      <content:encoded><![CDATA[<p>This is Part 2 covering the migration process and results. <a href="/2025/switching-from-node-to-bun-part-1/">Part 1</a> covered why I switched and the key improvements.</p>
<h2>The Migration: Step by Step</h2>
<p><strong>Phase 1: Package Management</strong></p>
<p>First, swap npm for Bun's package manager:</p>
<pre><code class="hljs language-javascript"><span class="hljs-comment">// Before (package.json scripts)</span>
<span class="hljs-string">"scripts"</span>: {
  <span class="hljs-string">"start"</span>: <span class="hljs-string">"ts-node src/index.ts"</span>,
  <span class="hljs-string">"build"</span>: <span class="hljs-string">"tsc"</span>,
  <span class="hljs-string">"dev"</span>: <span class="hljs-string">"nodemon --watch src --exec ts-node src/index.ts"</span>,
}

<span class="hljs-comment">// After</span>
<span class="hljs-string">"scripts"</span>: {
  <span class="hljs-string">"start"</span>: <span class="hljs-string">"bun src/index.ts"</span>,
  <span class="hljs-string">"build"</span>: <span class="hljs-string">"bun build ./src/index.ts --outdir ./dist --target=bun"</span>,
  <span class="hljs-string">"dev"</span>: <span class="hljs-string">"bun --watch src/index.ts"</span>,
}
</code></pre><p>Bun runs TypeScript files directly, which eliminates the separate compilation step and results in faster command execution.</p>
<p><strong>Phase 2: Build Configuration</strong></p>
<p>Bun's native bundler replaced TypeScript's compiler. Bun's sensible defaults eliminated dozens of lines of configuration.</p>
<p><strong>Phase 3: CI Integration</strong></p>
<p>Updated GitHub Actions:</p>
<pre><code class="hljs language-yaml">jobs<span class="hljs-punctuation">:</span>
  build<span class="hljs-punctuation">:</span>
    runs-on<span class="hljs-punctuation">:</span> ubuntu-latest
    steps<span class="hljs-punctuation">:</span>
      - uses<span class="hljs-punctuation">:</span> actions/checkout@v3
      - uses<span class="hljs-punctuation">:</span> oven-sh/setup-bun@v1
        with<span class="hljs-punctuation">:</span>
          bun-version<span class="hljs-punctuation">:</span> <span class="hljs-number">1.2</span><span class="hljs-number">.11</span>
      - run<span class="hljs-punctuation">:</span> bun install
      - run<span class="hljs-punctuation">:</span> bun run build
      - run<span class="hljs-punctuation">:</span> bun run typecheck
</code></pre><p>This also killed package-lock.json — 4,500+ lines removed — in favor of bun.lock.</p>
<p><strong>Phase 4: Refactoring File Operations</strong></p>
<p>The biggest payoff came from converting Node's fs operations to Bun's native File API.</p>
<h2>Using Claude Code for the Migration</h2>
<p>Claude Code identified file system operations that could use Bun's native APIs. The approach:</p>
<ol>
<li>Scan for patterns like <code>fs.readFile</code> and <code>fs.pathExists</code> across the codebase</li>
<li>Build a migration plan for each file based on usage patterns</li>
<li>Generate side-by-side rewrites with explanations of Bun-specific benefits</li>
<li>Apply changes file by file</li>
</ol>
<p>Claude suggested optimizations I wouldn't have considered, like using <code>arrayBuffer()</code> for binary file operations.</p>
<h2>Challenges</h2>
<p><strong>Ecosystem maturity</strong>: Not all Node packages work with Bun yet.</p>
<p><strong>Documentation gaps</strong>: Bun's docs are improving but still lag behind Node's.</p>
<p><strong>Error messages</strong>: Bun sometimes gives less informative errors than Node.</p>
<p><strong>Native extensions</strong>: Some C/C++ extensions don't work with Bun out of the box.</p>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Test your critical dependencies against Bun before migrating production workloads. Not all Node packages work yet, and error messages can be less informative.</p>
</div>
<p>The benefits outweighed these drawbacks for my use case.</p>
<h2>Results: Faster Builds, Cleaner Code</h2>
<p>Build time dropped from 37 seconds to 20 seconds — 46% faster. The development cycle feels different when you're not waiting.</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>Node.js</th>
<th>Bun</th>
<th>Improvement</th>
</tr>
</thead>
<tbody><tr>
<td>Cold start</td>
<td>1.2s</td>
<td>0.3s</td>
<td>75% faster</td>
</tr>
<tr>
<td>Build time</td>
<td>37s</td>
<td>20s</td>
<td>46% faster</td>
</tr>
<tr>
<td>File operations</td>
<td>Moderate</td>
<td>Very fast</td>
<td>~3-4x faster</td>
</tr>
<tr>
<td>TypeScript execution</td>
<td>Requires transpilation</td>
<td>Native support</td>
<td>No compile step</td>
</tr>
</tbody></table>
<p>The codebase is cleaner now too:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before (Node)</th>
<th>After (Bun)</th>
<th>Change</th>
</tr>
</thead>
<tbody><tr>
<td>Dependencies</td>
<td>20+</td>
<td>15</td>
<td>25% reduction</td>
</tr>
<tr>
<td>Configuration files</td>
<td>Multiple complex files</td>
<td>Minimal configs</td>
<td>Simpler</td>
</tr>
<tr>
<td>Boilerplate code</td>
<td>Considerable</td>
<td>Minimal</td>
<td>Cleaner</td>
</tr>
<tr>
<td>File system code</td>
<td>Complex</td>
<td>Direct and simple</td>
<td>Easier to maintain</td>
</tr>
</tbody></table>
<h2>Should You Switch?</h2>
<p>Bun fits well for:</p>
<ul>
<li>Static site generators and content-heavy applications</li>
<li>TypeScript projects that want to skip compilation</li>
<li>Projects bottlenecked by build performance</li>
<li>New projects that can fully adopt Bun's ecosystem</li>
</ul>
<p>For existing projects, migrate gradually. Start with build tooling, then move to runtime code as you validate compatibility. My path:</p>
<ol>
<li>Package management and simple commands</li>
<li>Build configuration</li>
<li>CI/CD pipelines</li>
<li>File operations refactored to native APIs</li>
<li>Dependency cleanup</li>
</ol>
<p>Bun is a real step forward for I/O-heavy JavaScript projects. My migration paid for itself within the first week of faster builds.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Switching from Node to Bun - Part 1]]></title>
      <link>https://kahwee.com/2025/switching-from-node-to-bun-part-1/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/switching-from-node-to-bun-part-1/</guid>
      <pubDate>Tue, 29 Apr 2025 16:00:00 GMT</pubDate>
      <description><![CDATA[Why switch from Node.js to Bun: Native File and Glob APIs, dramatic performance gains, and cleaner code. Real benchmarks and examples.]]></description>
      <category>technology</category>
      <category>performance</category>
      <category>web-development</category>
      <category>build-tool</category>
      <category>typescript</category>
      <content:encoded><![CDATA[<p>Build times dropped from 37 seconds to 20 seconds — a 46% improvement — after I switched this blog's codebase from Node.js to <a href="https://bun.sh" target="_blank" rel="noopener noreferrer nofollow">Bun</a>. The code got cleaner too. This is Part 1 covering why I switched and the key improvements. <a href="/2025/switching-from-node-to-bun-part-2/">Part 2</a> covers the migration process.</p>
<h2>Why Bun?</h2>
<p><strong>Native speed</strong>: Built on JavaScriptCore (WebKit's engine) instead of V8.</p>
<p><strong>Integrated tooling</strong>: Package manager, bundler, test runner in one binary.</p>
<p><strong>TypeScript support</strong>: First-class, no extra dependencies.</p>
<p><strong>Modern APIs</strong>: Native File and Glob implementations that outperform Node alternatives.</p>
<p><strong>TOML support</strong>: Built-in parser for configuration files.</p>
<h2>File Operations: Before and After</h2>
<p>The biggest wins came from replacing Node's file system operations with Bun's native File API.</p>
<p><strong>Before (Node.js with fs-extra):</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// Check if file paths exist</span>
<span class="hljs-keyword">if</span> (<span class="hljs-keyword">await</span> fs.<span class="hljs-title function_">pathExists</span>(withSlash)) {
  filePath = withSlash;
} <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">await</span> fs.<span class="hljs-title function_">pathExists</span>(withoutSlash)) {
  filePath = withoutSlash;
}

<span class="hljs-comment">// Read file content</span>
<span class="hljs-keyword">const</span> content = <span class="hljs-keyword">await</span> fs.<span class="hljs-title function_">readFile</span>(filePath);
<span class="hljs-keyword">let</span> htmlContent = content.<span class="hljs-title function_">toString</span>();
</code></pre><p><strong>After (Bun's File API):</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-comment">// Check if file paths exist</span>
<span class="hljs-keyword">if</span> (<span class="hljs-keyword">await</span> <span class="hljs-title class_">Bun</span>.<span class="hljs-title function_">file</span>(withSlash).<span class="hljs-title function_">exists</span>()) {
  filePath = withSlash;
} <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (<span class="hljs-keyword">await</span> <span class="hljs-title class_">Bun</span>.<span class="hljs-title function_">file</span>(withoutSlash).<span class="hljs-title function_">exists</span>()) {
  filePath = withoutSlash;
}

<span class="hljs-comment">// Read file content</span>
<span class="hljs-keyword">const</span> bunFile = <span class="hljs-title class_">Bun</span>.<span class="hljs-title function_">file</span>(filePath);
<span class="hljs-keyword">let</span> htmlContent = <span class="hljs-keyword">await</span> bunFile.<span class="hljs-title function_">text</span>();
</code></pre><p>The Bun version runs faster. The native File API handles existence checks and reads with less overhead than Node's implementation.</p>
<h2>Bun's Native Glob API</h2>
<p>I replaced recursive directory traversal with Bun's native Glob API.</p>
<p><strong>Before (Node.js recursion):</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getMarkdownFilesRecursively</span>(<span class="hljs-params"><span class="hljs-attr">dir</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-built_in">string</span>[]&gt; {
  <span class="hljs-keyword">const</span> entries = <span class="hljs-keyword">await</span> fs.<span class="hljs-title function_">readdir</span>(dir, { <span class="hljs-attr">withFileTypes</span>: <span class="hljs-literal">true</span> });

  <span class="hljs-keyword">const</span> files = <span class="hljs-keyword">await</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">all</span>(
    entries.<span class="hljs-title function_">map</span>(<span class="hljs-title function_">async</span> (entry) =&gt; {
      <span class="hljs-keyword">const</span> fullPath = path.<span class="hljs-title function_">join</span>(dir, entry.<span class="hljs-property">name</span>);
      <span class="hljs-keyword">if</span> (entry.<span class="hljs-title function_">isDirectory</span>()) {
        <span class="hljs-keyword">return</span> <span class="hljs-title function_">getMarkdownFilesRecursively</span>(fullPath);
      } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (entry.<span class="hljs-title function_">isFile</span>() &amp;&amp; entry.<span class="hljs-property">name</span>.<span class="hljs-title function_">endsWith</span>(<span class="hljs-string">".md"</span>)) {
        <span class="hljs-keyword">return</span> [fullPath];
      }
      <span class="hljs-keyword">return</span> [];
    }),
  );

  <span class="hljs-keyword">return</span> files.<span class="hljs-title function_">flat</span>();
}
</code></pre><p><strong>After (Bun's Glob API):</strong></p>
<pre><code class="hljs language-typescript"><span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">getMarkdownFilesRecursively</span>(<span class="hljs-params"><span class="hljs-attr">dir</span>: <span class="hljs-built_in">string</span></span>): <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-built_in">string</span>[]&gt; {
  <span class="hljs-keyword">const</span> glob = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Bun</span>.<span class="hljs-title class_">Glob</span>(<span class="hljs-string">"**/*.md"</span>);
  <span class="hljs-keyword">const</span> <span class="hljs-attr">files</span>: <span class="hljs-built_in">string</span>[] = [];

  <span class="hljs-keyword">for</span> <span class="hljs-title function_">await</span> (<span class="hljs-keyword">const</span> file <span class="hljs-keyword">of</span> glob.<span class="hljs-title function_">scan</span>({
    <span class="hljs-attr">cwd</span>: dir,
    <span class="hljs-attr">absolute</span>: <span class="hljs-literal">true</span>,
  })) {
    files.<span class="hljs-title function_">push</span>(file);
  }

  <span class="hljs-keyword">return</span> files;
}
</code></pre><div class="markdown-alert markdown-alert-tip">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path d="M10 1a6 6 0 0 0-3.815 10.631C7.237 12.5 8 13.443 8 14.456v.644a.75.75 0 0 0 .75.75h2.5a.75.75 0 0 0 .75-.75v-.644c0-1.013.762-1.957 1.815-2.825A6 6 0 0 0 10 1ZM8.863 17.414a.75.75 0 0 0-.226 1.483 9.066 9.066 0 0 0 2.726 0 .75.75 0 0 0-.226-1.483 7.553 7.553 0 0 1-2.274 0Z"></path></svg>Tip</p>
<p>The Bun implementation is simpler and faster — fewer lines, fewer edge cases, easier to maintain.</p>
</div>
<h2>Why Bun Is Fast</h2>
<p><strong>JavaScriptCore engine</strong>: Apple's JSC engine outperforms V8 for many operations.</p>
<p><strong>Native implementations</strong>: File, HTTP, and core APIs are written in Zig, not JavaScript. No abstraction layers.</p>
<p><strong>Optimized I/O</strong>: Bun's file operations bypass Node's libuv layer.</p>
<p><strong>Single binary</strong>: Runtime, bundler, and package manager share one process. No inter-process communication overhead.</p>
<p><strong>Built-in TypeScript</strong>: No separate compilation step.</p>
<p><strong>Zero-copy architecture</strong>: Minimizes memory copies, especially for file operations.</p>
<p>These architectural choices compound. I/O-intensive tasks like static site generation benefit the most.</p>
<p>Continue to <a href="/2025/switching-from-node-to-bun-part-2/">Part 2</a> for the migration process, challenges, and results.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Windsurf Slashes Prices]]></title>
      <link>https://kahwee.com/2025/windsurf-slashes-prices/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/windsurf-slashes-prices/</guid>
      <pubDate>Thu, 24 Apr 2025 19:00:00 GMT</pubDate>
      <description><![CDATA[Windsurf, an AI coding assistant, has dropped its prices, removed confusing flow action credits, and now offers one of the most affordable team plans in the market—just as rumors of a $3B OpenAI acquisition swirl.]]></description>
      <category>ai</category>
      <category>coding</category>
      <category>ai-tool</category>
      <category>pricing</category>
      <category>windsurf</category>
      <category>cursor</category>
      <category>github-copilot</category>
      <content:encoded><![CDATA[<p><strong>Windsurf</strong> slashed prices, killed flow action credits, and made its team plan one of the cheapest in the market. OpenAI is <a href="/2025/openai-playing-catch-up-in-the-ai-coding-wars/">reportedly in advanced talks to acquire Windsurf</a> for $3 billion. The timing of these changes feels deliberate.</p>
<h2>New Pricing</h2>
<p>Windsurf ditched usage-based flow action credits for flat-rate, per-user pricing. No more tracking multiple credit types for background AI actions. They credit better GPU optimization for making this work. Flow action credits never made sense to me — unnecessary complexity that confused users.</p>
<ul>
<li><strong>Pro plan:</strong> $15/month per user</li>
<li><strong>Teams plan:</strong> $30/month per user (down from $35)</li>
<li><strong>Enterprise:</strong> Custom pricing</li>
</ul>
<p>The <strong>Teams plan</strong> includes 500 credits per user with pooled add-on credits ($40 for 1,000 credits shared across the org). Seat management and analytics included. Advanced controls coming for an extra $10/user/month.</p>
<h2>How Cursor and GitHub Copilot Compare</h2>
<table>
<thead>
<tr>
<th>Product</th>
<th>Free Tier</th>
<th>Individual Plan (Pro)</th>
<th>Team/Business Plan</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://www.windsurf.ai" target="_blank" rel="noopener noreferrer nofollow"><strong>Windsurf</strong></a></td>
<td>No</td>
<td>$15/month</td>
<td>$30/user/month</td>
<td>No usage-based credits; pooled add-ons available</td>
</tr>
<tr>
<td><a href="https://cursor.sh" target="_blank" rel="noopener noreferrer nofollow"><strong>Cursor</strong></a></td>
<td>Yes (Hobby)</td>
<td>$20/month</td>
<td>$40/user/month</td>
<td>Unlimited completions on Pro; premium models with fair-use limits</td>
</tr>
<tr>
<td><a href="https://github.com/features/copilot" target="_blank" rel="noopener noreferrer nofollow"><strong>GitHub Copilot</strong></a></td>
<td>Yes (Free)</td>
<td>$10/month (Pro)</td>
<td>$19/user/month (Biz)</td>
<td>Pro+ at $39/month for 1,500 premium requests; extra $0.04/request</td>
</tr>
</tbody></table>
<h2>The Numbers That Matter</h2>
<p>At $30/user/month, Windsurf undercuts Cursor’s $40 and GitHub Copilot’s $39 Enterprise plan. The $15/month individual plan beats Cursor’s $20, though Copilot Pro at $10/month is still the cheapest option — with limits on advanced models and requests.</p>
<p>Dropping flow action credits removes the biggest source of user complaints: surprise charges and opaque billing. Whether the pricing holds post-acquisition is another question.</p>
]]></content:encoded>
    </item>
    <item>
      <title><![CDATA[Figma's Trademark Overreach: 'Dev Mode']]></title>
      <link>https://kahwee.com/2025/figma-trademark-overreach-dev-mode/</link>
      <guid isPermaLink="true">https://kahwee.com/2025/figma-trademark-overreach-dev-mode/</guid>
      <pubDate>Wed, 23 Apr 2025 00:39:00 GMT</pubDate>
      <description><![CDATA[Figma's trademark overreach: Covering "Dev Mode," "Config," "Schema." See questionable trademarks and why the community should push back.]]></description>
      <category>technology</category>
      <category>trademark</category>
      <category>figma</category>
      <category>lovable</category>
      <category>developer-tool</category>
      <category>legal</category>
      <content:encoded><![CDATA[<p>Figma sent Lovable a cease-and-desist over "Dev Mode." That's the headline. The deeper problem: Figma's trademark portfolio locks down terms developers have used for decades.</p>
<h2>Figma's Trademark List</h2>
<p>Figma has filed or registered these trademarks, most of which are generic software terms (see <a href="https://uspto.report/company/Figma-Inc" target="_blank" rel="noopener noreferrer nofollow">USPTO report</a>):</p>
<ul>
<li><strong>FIGMA</strong> (the company and product name)</li>
<li><strong>FORGE</strong></li>
<li><strong>SUMMIT</strong></li>
<li><strong>CONFIG</strong></li>
<li><strong>FIGJAM</strong></li>
<li><strong>SCHEMA</strong></li>
<li><strong>DEV MODE</strong></li>
<li><strong>NOTHING GREAT IS MADE ALONE</strong></li>
</ul>
<h2>The Worst Offenders</h2>
<p>FIGMA and FIGJAM make sense as brand names, but the rest do not.</p>
<ul>
<li><strong>DEV MODE</strong>: Universal shorthand for "developer mode." Chrome uses it. Xbox uses it. Jira uses it. Open-source projects have used it for decades (see <a href="https://www.theverge.com/news/649851/figma-dev-mode-trademark-loveable-dispute" target="_blank" rel="noopener noreferrer nofollow">The Verge</a>). Figma trademarked it in 2023 and now sends legal threats to startups.</li>
<li><strong>CONFIG</strong>: Trademarking "config" is like trying to own "settings."</li>
<li><strong>SCHEMA</strong>: A database and API term. Descriptive, not distinctive.</li>
<li><strong>SUMMIT</strong> and <strong>FORGE</strong>: Microsoft and Atlassian both use these names for events and tools.</li>
</ul>
<h2>Why the Legal Ground Is Weak</h2>
<div class="markdown-alert markdown-alert-warning">
<p class="markdown-alert-title"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>Warning</p>
<p>Figma isn't just protecting brand identity—they're trying to own words that developers have used for decades.</p>
</div>
<p>The "Dev Mode" trademark sits on the Supplemental Register, not the Principal Register. The USPTO put it there because the term is descriptive, not distinctive. That makes Figma's legal position fragile. Anyone who files for cancellation with evidence of prior widespread use has a strong case.</p>
<h2>Lovable Should Fight This</h2>
<p>Lovable refused to back down, and that is the right move. If they file for cancellation, Figma's claim on "Dev Mode" probably falls apart. The tech community needs more companies willing to challenge trademark grabs on common terminology.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>