<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ React - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ React - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 14 Aug 2026 16:24:59 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/reactjs/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Create a Scalable KYC Onboarding Flow in React with Shadcn UI ]]>
                </title>
                <description>
                    <![CDATA[ Every B2B SaaS product with a compliance requirement (like banking, lending, payroll, or crypto) hits the same wall early on: before you can let a business use your platform, you need to verify who th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-create-a-kyc-onboarding-flow-with-shadcn-ui/</link>
                <guid isPermaLink="false">6a7e08ac157d6ad1bb83d9cf</guid>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Thu, 13 Aug 2026 18:10:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c86a7b7f-9199-499c-8f61-7a1fda09f519.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every B2B SaaS product with a compliance requirement (like banking, lending, payroll, or crypto) hits the same wall early on: before you can let a business use your platform, you need to verify who they are.</p>
<p>That means collecting a business type, pulling in registration documents, and showing the user where their verification stands, all without making onboarding feel like a customs form.</p>
<p>This article breaks down a working three step KYC (Know Your Customer) flow built with Shadcn UI: a stepper for progress, a radio group for account type, a file upload zone for documents, and an alert for verification status. You'll see the actual component code, not a simplified stand-in, along with the reasoning behind each decision.</p>
<p>You can try the finished flow at <a href="http://onboarding-kyc-flow.vercel.app"><strong>onboarding-kyc-flow.vercel.app</strong></a>. Click through it once before reading on, as it makes the code below easier to follow. And it also comes in dark and light mode.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youre-building">What You're Building</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-radix-ui-vs-base-ui-which-primitives-this-flow-uses">Radix UI vs Base UI: Which Primitives this Flow Uses</a></p>
</li>
<li><p><a href="#heading-scaffolding-the-flow-with-v0-and-an-mcp-server">Scaffolding the Flow with v0 and an MCP Server</a></p>
</li>
<li><p><a href="#heading-step-1-account-type-with-a-radio-group">Step 1: Account Type with a Radio Group</a></p>
</li>
<li><p><a href="#heading-step-2-document-upload-with-drag-and-drop">Step 2: Document Upload with Drag and Drop</a></p>
</li>
<li><p><a href="#heading-step-3-verification-status-with-an-alert">Step 3: Verification Status with an Alert</a></p>
</li>
<li><p><a href="#heading-adding-a-stepper-to-the-flow">Adding a Stepper to the Flow</a></p>
</li>
<li><p><a href="#heading-small-details-that-make-it-feel-finished">Small Details that Make it Feel Finished</a></p>
</li>
<li><p><a href="#heading-accessibility-notes">Accessibility notes</a></p>
</li>
<li><p><a href="#heading-key-concepts-recap">Key Concepts Recap</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before working through this flow, you should be comfortable with React function components and hooks, specifically <code>useState</code>, <code>useRef</code>, and <code>useEffect</code>.</p>
<p>You should have:</p>
<ul>
<li><p>A Next.js project with the App Router and shadcn/ui already initialized, since this article doesn't cover that initial setup.</p>
</li>
<li><p>A v0 account is optional. You can also use Bolt or Lovable, which support the same shadcn MCP prompt feature.</p>
</li>
</ul>
<h2 id="heading-what-youre-building">What You're Building</h2>
<p>The flow has three steps:</p>
<ol>
<li><p><strong>Account type:</strong> The user picks Startup, Enterprise, or Government. This decision drives the rest of the experience. It's shown back to the user as a confirmation line, and would typically decide which workspace defaults get applied.</p>
</li>
<li><p><strong>Document upload:</strong> The user drags in a business registration document, a tax return, or a company registry export, in PDF or CSV format.</p>
</li>
<li><p><strong>Verification status:</strong> The user sees a live status: checking in progress, then either verified or an issue that needs attention.</p>
</li>
</ol>
<h2 id="heading-project-structure">Project Structure</h2>
<p>The project is a standard Next.js app with <a href="https://shadcnspace.com/"><strong>shadcn/ui</strong></a> already initialized. Here's the top-level layout:</p>
<pre><code class="language-javascript">onboarding-kyc-flow/
├── .vercel/
├── app/
├── components/
├── lib/
├── public/
├── .env.development.local
├── .gitignore
├── components.json
├── next-env.d.ts
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── tsconfig.json
└── tsconfig.tsbuildinfo
</code></pre>
<p><code>components.json</code> is the file the shadcn CLI reads to know where your components live and which style and primitives you're using. <code>components/</code> holds the shared UI pieces (Alert, Badge, Button, Card, Progress, RadioGroup, Separator) that the flow is built from. <code>lib/utils.ts</code> provides the <code>cn</code> helper used throughout the flow to combine conditional class names. <code>app/</code> holds the page itself, shown in full below.</p>
<h2 id="heading-radix-ui-vs-base-ui-which-primitives-this-flow-uses">Radix UI vs Base UI: Which Primitives this Flow Uses</h2>
<p><a href="https://shadcnspace.com/components"><strong>Shadcn components</strong></a> aren't tied to one underlying primitive library. Most of the ecosystem defaults to Radix UI, but Base UI has become a solid alternative, and it's what this flow is built on.</p>
<p>The underlying primitive library can affect how a component behaves and how you work with it in your project. If you're pulling components from a set like Shadcn UI, check which primitive library it targets before mixing components from different sources.</p>
<p>Mixing Radix-based and Base UI-based components generally works, but it means using two different unstyled primitive libraries in the same project. You can <a href="https://shadcnspace.com/blog/radix-ui-vs-base-ui"><strong>compare Radix UI and Base UI here</strong></a>.</p>
<h2 id="heading-scaffolding-the-flow-with-v0-and-an-mcp-server">Scaffolding the Flow with v0 and an MCP Server</h2>
<p>An MCP (Model Context Protocol) server exposes a component library to an AI coding assistant as a set of callable tools. Instead of the assistant guessing at component names and props from training data, it queries the server for the real, current API.</p>
<p>This matters here specifically, since there are now several shadcn-style component sets with similar names and different props.</p>
<p>The Shadcn Components library publishes an MCP server for its free set, connected to v0 by following its <a href="https://shadcnspace.com/docs/getting-started/mcp-server-docs"><strong>getting started guide</strong></a>. The video below covers the connection step by step. The same generated output can also be copied into Lovable or Bolt through their copy prompt feature, so the workflow isn't locked to one AI builder.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/ymTlzbkvvPk" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>The prompt used to scaffold this flow looked like this:</p>
<blockquote>
<p>Create an Enterprise SaaS Onboarding &amp; KYC Flow. Use free components of the shadcn space MCP server: shadcn alert, shadcn radio group, shadcn stepper, shadcn file upload. Only use free components, not pro ones, and list which free component was used for each part.</p>
<p>Step 1: Account Type (stepper) - radio group for Startup, Enterprise, or Government</p>
<p>Step 2: Upload Documents (stepper) - file upload for a business registration document</p>
<p>Step 3: Verification (stepper) - alert showing verification status</p>
</blockquote>
<p>This produces a working first draft fast. What follows is the result after cleaning that draft up: real state management, real validation, and states that a generated draft tends to skip.</p>
<h2 id="heading-step-1-account-type-with-a-radio-group">Step 1: Account Type with a Radio Group</h2>
<p>Account type is the first decision in the flow because it's the one most likely to affect what comes after it. Asking it early keeps the rest of the flow feeling relevant to the choice the user just made.</p>
<pre><code class="language-javascript">const tiers: { id: Tier; name: string; description: string; tag: string }[] = [
  { id: 'startup', name: 'Startup', description: 'For teams building and scaling fast', tag: 'Up to 25 seats' },
  { id: 'enterprise', name: 'Enterprise', description: 'For established teams with advanced needs', tag: 'Unlimited seats' },
  { id: 'government', name: 'Government', description: 'For public sector and regulated teams', tag: 'FedRAMP-ready' },
]
</code></pre>
<pre><code class="language-javascript">&lt;RadioGroup value={tier} onValueChange={(value) =&gt; setTier(value as Tier)} className="grid gap-3"&gt;
  &lt;fieldset className="contents"&gt;
    &lt;legend className="sr-only"&gt;Account type&lt;/legend&gt;
    {tiers.map((item) =&gt; (
      &lt;label
        key={item.id}
        htmlFor={item.id}
        className={cn(
          'flex cursor-pointer items-start gap-4 rounded-xl border p-4 transition-colors hover:border-primary/50',
          tier === item.id &amp;&amp; 'border-primary bg-primary/5'
        )}
      &gt;
        &lt;RadioGroupItem value={item.id} id={item.id} className="mt-0.5" /&gt;
        &lt;span className="flex flex-1 flex-col gap-1"&gt;
          &lt;span className="flex flex-wrap items-center gap-2 text-sm font-semibold"&gt;
            {item.name}
            {item.id === 'enterprise' &amp;&amp; &lt;Badge variant="secondary"&gt;Recommended&lt;/Badge&gt;}
          &lt;/span&gt;
          &lt;span className="text-sm text-muted-foreground"&gt;{item.description}&lt;/span&gt;
          &lt;span className="mt-1 font-mono text-[11px] uppercase tracking-wide text-muted-foreground"&gt;{item.tag}&lt;/span&gt;
        &lt;/span&gt;
      &lt;/label&gt;
    ))}
  &lt;/fieldset&gt;
&lt;/RadioGroup&gt;
</code></pre>
<p>Two things worth noticing here. The tier data lives in a plain array outside the component, so adding a fourth tier later is a one-line change, not a markup change. And the <code>fieldset</code> with a visually hidden (<code>sr-only</code>) legend groups the three options as one related choice for screen readers. Sighted users never see it, since the card title above already states "Choose your account type" visually.</p>
<p>This step uses a <a href="https://shadcnspace.com/components/radio-group"><strong>shadcn radio group</strong></a> rather than a select or checkboxes, since account type is a single, mutually exclusive choice, and a radio group is the only one of the three that makes both the options and the current selection visible at a glance.</p>
<h3 id="heading-live-preview"><strong>Live Preview:</strong></h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/5ad2e892-88ae-4e6b-b83d-451ebe13dc74.png" alt="Step 1: Account type with a radio group" style="display:block;margin:0 auto" width="1902" height="946" loading="lazy">

<hr>
<h2 id="heading-step-2-document-upload-with-drag-and-drop">Step 2: Document Upload with Drag and Drop</h2>
<p>The upload zone needs to handle three states cleanly: nothing selected yet, a file selected and ready, and a rejected file with a specific reason why.</p>
<pre><code class="language-javascript">function FileUpload({ file, onFile, onRemove, error }: {
  file: File | null
  onFile: (file: File) =&gt; void
  onRemove: () =&gt; void
  error: string
}) {
  const inputRef = useRef&lt;HTMLInputElement&gt;(null)
  const [dragging, setDragging] = useState(false)

  const accept = (candidate: File) =&gt; {
    if (candidate.type !== 'application/pdf' &amp;&amp; candidate.type !== 'text/csv' &amp;&amp; !candidate.name.toLowerCase().endsWith('.csv')) {
      return 'Upload a PDF or CSV file only.'
    }
    if (candidate.size &gt; 10 * 1024 * 1024) {
      return 'Files must be smaller than 10 MB.'
    }
    onFile(candidate)
    return ''
  }

  return (
    &lt;div className="flex flex-col gap-3"&gt;
      {!file ? (
        &lt;button
          type="button"
          className={cn(
            'group flex min-h-44 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/30 px-6 text-center transition-colors hover:border-primary hover:bg-primary/5',
            dragging &amp;&amp; 'border-primary bg-primary/10'
          )}
          onClick={() =&gt; inputRef.current?.click()}
          onDragOver={(event) =&gt; { event.preventDefault(); setDragging(true) }}
          onDragLeave={() =&gt; setDragging(false)}
          onDrop={(event) =&gt; {
            event.preventDefault()
            setDragging(false)
            const dropped = event.dataTransfer.files[0]
            if (dropped) accept(dropped)
          }}
        &gt;
          &lt;input
            ref={inputRef}
            className="sr-only"
            type="file"
            accept=".pdf,.csv,application/pdf,text/csv"
            onChange={(event) =&gt; {
              const selected = event.target.files?.[0]
              if (selected) accept(selected)
            }}
          /&gt;
          &lt;span className="mb-3 flex size-11 items-center justify-center rounded-lg border bg-background text-primary shadow-sm"&gt;
            &lt;UploadCloud className="size-5" aria-hidden="true" /&gt;
          &lt;/span&gt;
          &lt;span className="text-sm font-semibold"&gt;Drop your business document here&lt;/span&gt;
          &lt;span className="mt-1 text-xs text-muted-foreground"&gt;or click to browse · PDF or CSV · max 10 MB&lt;/span&gt;
        &lt;/button&gt;
      ) : (
        &lt;div className="flex items-center gap-3 rounded-xl border bg-muted/30 p-4"&gt;
          &lt;span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary"&gt;
            &lt;FileText className="size-5" aria-hidden="true" /&gt;
          &lt;/span&gt;
          &lt;div className="min-w-0 flex-1"&gt;
            &lt;p className="truncate text-sm font-semibold"&gt;{file.name}&lt;/p&gt;
            &lt;p className="text-xs text-muted-foreground"&gt;{(file.size / 1024 / 1024).toFixed(2)} MB · Ready to verify&lt;/p&gt;
          &lt;/div&gt;
          &lt;Badge variant="secondary" className="hidden sm:inline-flex"&gt;Uploaded&lt;/Badge&gt;
          &lt;Button type="button" variant="ghost" size="icon-sm" aria-label="Remove file" onClick={onRemove}&gt;
            &lt;X className="size-4" aria-hidden="true" /&gt;
          &lt;/Button&gt;
        &lt;/div&gt;
      )}
      {error &amp;&amp; (
        &lt;Alert variant="destructive"&gt;
          &lt;AlertCircle className="size-4" aria-hidden="true" /&gt;
          &lt;AlertTitle&gt;Unsupported document&lt;/AlertTitle&gt;
          &lt;AlertDescription&gt;{error}&lt;/AlertDescription&gt;
        &lt;/Alert&gt;
      )}
    &lt;/div&gt;
  )
}
</code></pre>
<p>The <code>accept</code> function is the whole validation layer, and it runs from two different places: the change handler on the hidden file input, and the drop handler on the drag zone.</p>
<p>Both paths call the same function, so a file dragged in gets the same validation checks as a file selected by clicking browse. It ensures that only <strong>PDF or CSV files</strong> are allowed, regardless of how the file is added.</p>
<p>This is where <a href="https://shadcnspace.com/components/file-upload"><strong>shadcn file upload</strong></a> earns its place over a plain <code>&lt;input type="file"&gt;</code>: the drag zone, the selected state, and the rejected state are all handled as one component instead of three separate pieces wired together by hand.</p>
<h3 id="heading-live-preview"><strong>Live Preview:</strong></h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/959c806e-9e52-43f9-ad7f-c2855329ac5c.png" alt="Step 2: Document upload with drag and drop" style="display:block;margin:0 auto" width="1919" height="945" loading="lazy">

<h2 id="heading-step-3-verification-status-with-an-alert">Step 3: Verification Status with an Alert</h2>
<p>Verification isn't instant, so the interface needs to say clearly what's happening and what happens next, rather than showing a spinner with no explanation.</p>
<pre><code class="language-javascript">{verified ? (
  &lt;Alert className="border-primary/30 bg-primary/5"&gt;
    &lt;CheckCircle2 className="size-4 text-primary" aria-hidden="true" /&gt;
    &lt;AlertTitle&gt;Verification complete&lt;/AlertTitle&gt;
    &lt;AlertDescription&gt;
      Your {selectedTier.name.toLowerCase()} workspace is ready to configure.
    &lt;/AlertDescription&gt;
  &lt;/Alert&gt;
) : (
  &lt;&gt;
    &lt;Alert&gt;
      &lt;AlertCircle className="size-4" aria-hidden="true" /&gt;
      &lt;AlertTitle&gt;Verification in progress&lt;/AlertTitle&gt;
      &lt;AlertDescription&gt;
        This usually takes a few moments. You can keep this tab open while we finish.
      &lt;/AlertDescription&gt;
    &lt;/Alert&gt;
    &lt;div className="flex flex-col gap-3"&gt;
      &lt;div className="flex items-center justify-between text-sm"&gt;
        &lt;span className="font-medium"&gt;Checking business registry&lt;/span&gt;
        &lt;span className="font-mono text-xs text-muted-foreground"&gt;{checking ? '68%' : '100%'}&lt;/span&gt;
      &lt;/div&gt;
      &lt;Progress value={checking ? 68 : 100} /&gt;
      &lt;div className="flex items-center gap-2 text-xs text-muted-foreground"&gt;
        &lt;Building2 className="size-3.5" aria-hidden="true" /&gt; Matching company details and tax identifiers
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/&gt;
)}
</code></pre>
<p>Pairing the <a href="https://shadcnspace.com/components/alert"><strong>shadcn alert</strong></a> with a progress bar does two jobs at once: the alert states the current status in words, while the progress bar gives a rough sense of how much is left, without promising a specific time. Neither one alone tells the full story, the alert alone feels static, and a progress bar alone doesn't explain what's actually being checked.</p>
<p>Worth adding here, and easy to skip when a demo only shows the success path: a mismatch state, where the tax ID on the document doesn't match the company registry, deserves its own alert with a clear next step: contact support or re-upload a corrected document. It's not shown above, since the flow currently resolves to either checking or verified, but it's the state a production version of this flow would hit the most.</p>
<h3 id="heading-live-preview"><strong>Live Preview:</strong></h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/55f02dfe-af23-4d07-aaaa-868e2a7fb834.png" alt="Step 3: Verification status with an alert" style="display:block;margin:0 auto" width="1919" height="946" loading="lazy">

<hr>
<h2 id="heading-adding-a-stepper-to-the-flow">Adding a Stepper to the Flow</h2>
<p>The stepper is the visual anchor of the whole flow. It's the piece that tells the user how much is left before the checking and account-type-selecting are done.</p>
<pre><code class="language-javascript">function Stepper({ current }: { current: Step }) {
  return (
    &lt;nav
      aria-label="Onboarding progress"
      className="grid grid-cols-[minmax(0,1fr)_minmax(2rem,5rem)_minmax(0,1fr)_minmax(2rem,5rem)_minmax(0,1fr)] items-start gap-0"
    &gt;
      {steps.map((step, index) =&gt; (
        &lt;div key={step.number} className="contents"&gt;
          &lt;div className="flex min-w-0 flex-col items-center text-center"&gt;
            &lt;div
              className={cn(
                'flex size-9 items-center justify-center rounded-full border text-sm font-semibold transition-colors',
                current &gt; step.number
                  ? 'border-primary bg-primary text-primary-foreground'
                  : current === step.number
                  ? 'border-primary bg-primary/10 text-primary'
                  : 'border-border bg-background text-muted-foreground'
              )}
              aria-current={current === step.number ? 'step' : undefined}
            &gt;
              {current &gt; step.number ? &lt;Check className="size-4" aria-hidden="true" /&gt; : step.number}
            &lt;/div&gt;
            &lt;div className="mt-2 min-w-0"&gt;
              &lt;p className={cn('truncate text-sm font-semibold', current &gt;= step.number ? 'text-foreground' : 'text-muted-foreground')}&gt;
                {step.label}
              &lt;/p&gt;
              &lt;p className="mt-1 hidden text-xs leading-5 text-muted-foreground sm:block"&gt;{step.caption}&lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
          {index &lt; steps.length - 1 &amp;&amp; (
            &lt;div className={cn('mt-4 h-px w-full', current &gt; step.number ? 'bg-primary' : 'bg-border')} /&gt;
          )}
        &lt;/div&gt;
      ))}
    &lt;/nav&gt;
  )
}
</code></pre>
<p>The <code>current &gt; step.number</code> check keeps the entire stepper in sync with a single comparison. It determines the circle’s fill color, decides when the step number should be replaced by a checkmark, and controls whether the connecting line to the next step is filled.</p>
<p>This is important because the stepper only needs one piece of state, <code>step</code>, from the parent component. It doesn’t need to know why the user is on step 2, it only needs to know which step is currently active and update its visual state accordingly.</p>
<p>The "continue" logic that actually advances <code>step</code> lives outside the stepper itself:</p>
<pre><code class="language-javascript">const continueStep = () =&gt; {
  if (step === 1) setStep(2)
  else if (step === 2 &amp;&amp; file) {
    setStep(3)
    setChecking(true)
    window.setTimeout(() =&gt; {
      setChecking(false)
      setVerified(true)
    }, 1400)
  }
}
</code></pre>
<p>Keeping this in the page component, not inside the <a href="https://shadcnspace.com/components/stepper"><strong>shadcn stepper</strong></a> itself, is what keeps the stepper reusable. It only renders progress. Whether the user is allowed to move forward, a file is required on step 2, or whether nothing is required on step 1, is a decision for the flow around it to make.</p>
<h2 id="heading-small-details-that-make-it-feel-finished">Small Details that Make it Feel Finished</h2>
<p>A few things in this build are easy to skip but change how the flow feels in practice:</p>
<ul>
<li><p><strong>A dark mode toggle</strong> in the header, wired to a <code>darkMode</code> state that toggles a class on <code>document.documentElement</code>. It's small, but it means the flow doesn't fight a user's system theme preference.</p>
</li>
<li><p><strong>A security note</strong> in the sidebar, stating documents are encrypted and deleted after verification. This is copy, not code, but it answers the question a corporate user is quietest about and most worried by: what happens to the file after I upload it.</p>
</li>
<li><p><strong>A "Selected" confirmation line</strong> under the radio group, restating the chosen tier in plain text. A small detail, but it removes any doubt about what was actually selected before moving on.</p>
</li>
</ul>
<p>If you're looking to wrap a flow like this inside a full application shell, with navigation and a dashboard around it, the <a href="https://shadcnspace.com/admin-dashboard"><strong>Shadcn Dashboard</strong></a> starter uses the same component set. It's a reasonable base to extend from rather than building a shell from scratch.</p>
<h3 id="heading-live-preview">Live Preview:</h3>
<p><a class="embed-card" href="https://onboarding-kyc-flow.vercel.app/">https://onboarding-kyc-flow.vercel.app/</a></p>

<p>This project is open source, and you can easily download the zip and if you like. Please consider giving it a star.</p>
<ul>
<li><a href="https://github.com/vaibhavsudo/onboarding-kyc-flow"><strong>Github Repo</strong></a></li>
</ul>
<h2 id="heading-accessibility-notes">Accessibility notes</h2>
<ul>
<li><p>The stepper's <code>nav</code> element has an <code>aria-label</code>, and the current step carries <code>aria-current="step"</code>, so assistive technology can identify progress without relying on visual position alone.</p>
</li>
<li><p>The radio group sits inside a <code>fieldset</code> with a screen-reader-only <code>legend</code>, grouping the three account types as one decision.</p>
</li>
<li><p>Icons throughout (<code>Check</code>, <code>AlertCircle</code>, <code>UploadCloud</code>, and so on) carry <code>aria-hidden="true"</code>, since they're decorative next to text that already states the same information. This stops screen readers from announcing redundant icon labels.</p>
</li>
<li><p>The remove-file button has an explicit <code>aria-label</code>, since its visible content is an icon only, with no text.</p>
</li>
</ul>
<h2 id="heading-key-concepts-recap">Key Concepts Recap</h2>
<ul>
<li><p>Account type comes first because it's the one decision most likely to affect the rest of the flow, and it's kept in state at the page level, not inside the radio group itself.</p>
</li>
<li><p>File validation runs in a shared function used by both the drag-and-drop path and the click-to-browse path, so both paths apply the same PDF/CSV validation.</p>
</li>
<li><p>Verification status is communicated with both words (the alert) and a rough sense of progress (the progress bar), since either one alone leaves out part of the picture.</p>
</li>
<li><p>The stepper is a pure display component driven by a single <code>step</code> value from its parent. The logic for whether the user can advance lives outside it, not inside it.</p>
</li>
<li><p>Small, non-technical details (like a security note, a confirmation line, or a theme toggle) do as much for how finished a flow feels as any of the four core components.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>None of the four components in this flow are complicated individually. What makes a KYC flow work is the decisions around them: which choice comes first, where validation actually runs, and how honestly the interface talks to the user while something outside their control is being checked.</p>
<p>Whether the first draft comes from typing every line by hand or from scaffolding it with an MCP server and v0, that's the part worth spending time getting right before it ships.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://shadcnspace.com/components"><strong>Shadcn Components</strong></a>, the free component set used in this flow</p>
</li>
<li><p><a href="https://shadcnspace.com/"><strong>ShadcnSpace</strong></a>, the base library these components extend</p>
</li>
<li><p><a href="https://shadcnspace.com/mcp"><strong>MCP server walkthrough</strong></a></p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current"><strong>MDN: ARIA current attribute</strong></a></p>
</li>
<li><p><a href="https://modelcontextprotocol.io/"><strong>Model Context Protocol specification</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Open Source SaaS Landing Page Template with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repeti ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-landing-page-nextjs-shadcn/</link>
                <guid isPermaLink="false">6a70e0650d58f4d80d2eca59</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcnui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ash ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 18:39:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/33d9aa05-3187-4d07-8aea-bcd83fe13ac0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repetition, not engineering.</p>
<p>So I built and open-sourced a complete SaaS landing page template called <a href="https://www.shadcndeck.com/templates/chatdeck-saas-landing-page">ChatDeck</a>. It runs on Next.js 16, React 19, shadcn/ui with the new <code>base-nova</code> style, Tailwind CSS v4, and TypeScript. The full source is on GitHub under the MIT license. I built and open-sourced this template, and everything here comes from decisions made during that process.</p>
<p>Building it forced me to make real decisions on a stack that moved significantly in the past 12 months. This article is about those decisions: what worked, what didn't, and what I'd do differently if I started today.</p>
<p><strong>Prerequisites:</strong> This article assumes you're comfortable with React and TypeScript. Some familiarity with the Next.js App Router is helpful but not required. Each lesson is explained from first principles.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</a></p>
</li>
<li><p><a href="#heading-getting-started">Getting Started</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's Newbase-novaStyle Changes What "Accessible" Means</a></p>
</li>
<li><p><a href="#heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</a></p>
</li>
<li><p><a href="#heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</a></p>
</li>
<li><p><a href="#heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</a></p>
</li>
<li><p><a href="#heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</a></p>
</li>
<li><p><a href="#heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</a></p>
</li>
<li><p><a href="#heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</a></p>
</li>
<li><p><a href="#heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</a></p>
</li>
<li><p><a href="#heading-what-id-do-differently">What I'd Do Differently</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</h2>
<p>Before getting into the code, here's what the template runs on. Each choice was deliberate — none of these are defaults you get from <code>create-next-app</code>.</p>
<table>
<thead>
<tr>
<th>Technology</th>
<th>Version</th>
<th>Why I chose it</th>
</tr>
</thead>
<tbody><tr>
<td>Next.js</td>
<td>^16.0.3</td>
<td>App Router gives you React Server Components out of the box. Static sections like Hero and Features render on the server — no client-side JS needed for content that never changes.</td>
</tr>
<tr>
<td>React</td>
<td>19.2.0</td>
<td>React 19 stabilises the <code>use</code> hook and concurrent features. Staying on the latest version means the template doesn't immediately feel stale.</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td>^4.13.0 (CLI)</td>
<td>Components are copied into your codebase, not installed as a package. You own the code. No version lock-in, no fighting library defaults when you need to customize.</td>
</tr>
<tr>
<td>Base UI (<code>@base-ui/react</code>)</td>
<td>^1.6.0</td>
<td>shadcn/ui's new <code>base-nova</code> style uses Base UI instead of Radix as its headless primitive layer. It has a smaller peer dependency footprint and tighter ARIA integration. More on this in Lesson 1.</td>
</tr>
<tr>
<td>Tailwind CSS</td>
<td>^4</td>
<td>v4 moves theme configuration from a JavaScript config file into CSS directly. Custom animations, color tokens, and radius scales all live in <code>globals.css</code>. More on this in Lesson 2.</td>
</tr>
<tr>
<td>Motion (<code>motion/react</code>)</td>
<td>^12.23.24</td>
<td>The rebranded Framer Motion. Handles entrance animations on the Hero and scroll-triggered animations on the Features section. Chosen over CSS animations because staggered sequences are much simpler to manage.</td>
</tr>
<tr>
<td>TypeScript</td>
<td>^5</td>
<td>Full type safety throughout. Component props, icon maps, pricing plan objects — all typed. Catches errors at build time, not at runtime.</td>
</tr>
<tr>
<td>Lucide React</td>
<td>^0.553.0</td>
<td>Consistent, well-maintained icon set that works cleanly with Tailwind's <code>size-*</code> utilities. No custom SVG wrangling needed for UI icons.</td>
</tr>
</tbody></table>
<p>The most interesting decisions in this list are the ones that reflect how the ecosystem changed in the past year: Base UI replacing Radix inside shadcn/ui, and Tailwind v4's shift to CSS-first configuration. The lessons below walk through each of these in detail, starting with the choices that had the biggest impact on how the code is actually written.</p>
<h2 id="heading-getting-started">Getting Started</h2>
<p>Before diving into the lessons, here's how to get the project running locally. Having it open alongside this article makes the code examples easier to follow.</p>
<pre><code class="language-bash">git clone https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template.git
cd chatdeck-shadcn-saas-landing-page-template
pnpm install
pnpm dev
</code></pre>
<p>Open <code>http://localhost:3000</code> and you'll see the full landing page running locally.</p>
<p>All section content lives as plain TypeScript arrays inside each Block component. To change the features, edit the <code>features</code> array in <code>FeatureSection.tsx</code>. To change pricing tiers, edit the <code>plans</code> array in <code>PricingSection.tsx</code>. No CMS, no config files — just TypeScript objects.</p>
<p>To customize colors, update the OKLCH values in <code>app/globals.css</code> under the <code>:root</code> block. Change <code>--primary</code> and every button, link, and accent color updates across the entire template.</p>
<p>Deploy to Vercel with a single <code>vercel</code> command or by pushing to GitHub and connecting the repo. Next.js is detected automatically.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>Here's the full directory layout before we go through each part of it:</p>
<pre><code class="language-plaintext">chatdeck/
├── app/
│   ├── globals.css         # Theme tokens + custom animations (Tailwind v4 @theme)
│   ├── layout.tsx          # Root layout — Navbar, Footer, fonts
│   └── page.tsx            # Section imports — 16 lines
├── components/
│   ├── Blocks/             # Page sections (Hero, Features, Pricing, etc.)
│   ├── ui/                 # shadcn/ui components — base-nova style
│   └── navbar.tsx          # Scroll-aware sticky navbar
└── lib/
    └── utils.ts            # cn() helper (clsx + tailwind-merge)
</code></pre>
<p>The key separation is <code>Blocks/</code> vs <code>ui/</code>. The <code>ui/</code> folder holds primitive components — Button, Badge, Accordion — that come from shadcn/ui and rarely change. The <code>Blocks/</code> folder holds page-level sections that are specific to this template and change often. When you're customising, you mostly work in <code>Blocks/</code>. When you upgrade <a href="https://www.shadcndeck.com/blog/shadcn-components">shadcn/ui components</a>, you touch <code>ui/</code>.</p>
<p>The lessons below go through specific files in this structure piece by piece: <code>components.json</code> and <code>ui/accordion.tsx</code> in Lesson 1, <code>app/globals.css</code> in Lessons 2 and 3, <code>app/page.tsx</code> in Lesson 4, and the individual Block components in Lessons 5 through 8.</p>
<h2 id="heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's New <code>base-nova</code> Style Changes What "Accessible" Means</h2>
<p>If you've used shadcn/ui before, you know the default setup uses <strong>Radix UI</strong> primitives, headless components that handle focus management, keyboard navigation, and ARIA attributes. Radix has been the default for years.</p>
<p>But shadcn/ui introduced a new style in 2025 called <code>base-nova</code>, which replaces <a href="https://www.shadcndeck.com/blog/radix-vs-base-ui">Radix with <strong>Base UI</strong></a>, the headless primitive library from MUI.</p>
<p>Based on shadcn's public direction and the components released through 2025, <code>base-nova</code> appears to be the intended default going forward (though shadcn hasn't yet deprecated the Radix style).</p>
<p>In the project's <code>components.json</code>:</p>
<pre><code class="language-json">{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "base-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "iconLibrary": "lucide"
}
</code></pre>
<p>The <code>"style": "base-nova"</code> line means every component the shadcn/ui CLI installs wraps Base UI primitives instead of Radix. To understand what this changes in practice, here's what the same Accordion trigger component looks like in the older Radix-based default style:</p>
<pre><code class="language-tsx">// Radix-based default style (the old way)
import * as AccordionPrimitive from "@radix-ui/react-accordion"

const AccordionTrigger = React.forwardRef&lt;
  React.ElementRef&lt;typeof AccordionPrimitive.Trigger&gt;,
  React.ComponentPropsWithoutRef&lt;typeof AccordionPrimitive.Trigger&gt;
&gt;(({ className, children, ...props }, ref) =&gt; {
  const [isOpen, setIsOpen] = React.useState(false)

  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        ref={ref}
        className={cn("flex flex-1 items-center justify-between ...", className)}
        onClick={() =&gt; setIsOpen(!isOpen)}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "hidden" : "block"
          )}
        /&gt;
        &lt;ChevronUpIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "block" : "hidden"
          )}
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
})
</code></pre>
<p>Notice the <code>useState(false)</code> tracking whether the accordion is open, and the <code>onClick</code> handler that toggles it. This means the component has to manually keep its own <code>isOpen</code> state in sync with what Radix internally knows about the open/closed state.</p>
<p>Now here's the same component using the <code>base-nova</code> style with Base UI:</p>
<pre><code class="language-tsx">// components/ui/accordion.tsx — base-nova style (the new way)
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"

function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger relative flex flex-1 items-start ...",
          className
        )}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
        /&gt;
        &lt;ChevronUpIcon
          className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
}
</code></pre>
<p>No <code>useState</code>. No <code>onClick</code>. No <code>isOpen</code> variable. The chevron visibility is controlled entirely by <code>group-aria-expanded/accordion-trigger:hidden</code> — a Tailwind class that reads the <code>aria-expanded</code> attribute Base UI sets automatically on the trigger element.</p>
<p><strong>The lesson here:</strong> in the Radix version, you have two parallel systems: the component's own <code>isOpen</code> state, and the ARIA attributes that the library manages separately for screen readers. These can drift out of sync — for example, if the accordion closes via keyboard navigation, the ARIA state updates correctly but your <code>isOpen</code> state doesn't unless you wire up the right callbacks. In the Base UI version, there is only one system. ARIA state IS the state. Tailwind reads it directly. There's nothing to keep in sync and nothing that can drift.</p>
<p><strong>Lesson:</strong> use the primitive library's ARIA attributes as your source of truth for visual state. If your headless component library already sets <code>aria-expanded</code>, <code>aria-selected</code>, or <code>aria-checked</code>, Tailwind can respond to those directly with <code>aria-*</code> variant classes — no parallel JavaScript state needed.</p>
<p>So when you install shadcn/ui today, choose <code>base-nova</code> over the default Radix style. You get tighter Base UI integration, a smaller peer dependency footprint, and components that are more aligned with where the ecosystem is moving.</p>
<h2 id="heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</h2>
<p>Tailwind CSS v4 moves primary theme configuration out of the JavaScript config file and into CSS. This sounds small. In practice, it changes how you think about the entire theming system.</p>
<p>In Tailwind v3, you'd extend the theme in <code>tailwind.config.js</code>:</p>
<pre><code class="language-js">// OLD — tailwind.config.js (v3)
module.exports = {
  theme: {
    extend: {
      animation: {
        marquee: "marquee 40s linear infinite",
      },
      keyframes: {
        marquee: {
          from: { transform: "translateX(0)" },
          to: { transform: "translateX(calc(-100% - var(--gap)))" },
        },
      },
    },
  },
}
</code></pre>
<p>In Tailwind v4, that same configuration lives in your CSS file instead:</p>
<pre><code class="language-css">/* app/globals.css — Tailwind v4 */
@import "tailwindcss";

@theme inline {
  --animate-marquee: marquee var(--duration) infinite linear;
  --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;

  @keyframes marquee {
    from { transform: translateX(0); }
    to   { transform: translateX(calc(-100% - var(--gap))); }
  }

  --radius-2xl: calc(var(--radius) * 1.8);
  --radius-3xl: calc(var(--radius) * 2.2);
  --radius-4xl: calc(var(--radius) * 2.6);
}
</code></pre>
<p>The <code>@theme inline</code> block extends Tailwind's design token system. Define <code>--animate-marquee</code> here and you can use <code>className="animate-marquee"</code> anywhere in your components. Tailwind generates the utility class automatically from the CSS variable.</p>
<p>Custom animations, radius scales, and color tokens all live in CSS now. The benefit is that CSS is where styles belong. The config file was always an indirection layer between "what I want my design system to look like" and "where that actually lives." Tailwind v4 removes the indirection.</p>
<p><strong>The friction:</strong> if you start a Tailwind v4 project with a v3 mental model, you'll spend time looking for theme config in the wrong place. Read the v4 migration guide before you start, not after you're confused.</p>
<p><strong>Lesson:</strong> move your mental model of "theme config" from JavaScript to CSS. In Tailwind v4, if you want a custom animation, a new radius scale, or a color token, define it in <code>@theme inline</code> inside <code>globals.css</code>. That's where it belongs, and that's where every developer on your team will find it.</p>
<h2 id="heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</h2>
<p>The template uses OKLCH color values throughout, not hex or HSL:</p>
<pre><code class="language-css">:root {
  --background: oklch(1 0 0);        /* white */
  --foreground: oklch(0.145 0 0);    /* near-black */
  --primary: oklch(0.205 0 0);
  --border: oklch(0.922 0 0);
}

.dark {
  --background: oklch(0.145 0 0);    /* near-black */
  --foreground: oklch(0.985 0 0);    /* near-white */
  --primary: oklch(0.922 0 0);
  --border: oklch(1 0 0 / 10%);      /* white at 10% opacity */
}
</code></pre>
<p>OKLCH is a perceptually uniform color space. When you increase the lightness value in OKLCH, the color actually <em>looks</em> lighter to human eyes, consistently. Hex and HSL don't guarantee this. You can increase the <code>L</code> in HSL and get a color that looks the same or even darker depending on the hue.</p>
<p>For dark mode specifically, this matters because you're inverting a whole color system. With HSL, you'll often end up manually tweaking individual color values until contrast ratios look right. With OKLCH, increasing or decreasing the lightness value gives you predictable results across all your tokens.</p>
<p>The dark mode switch itself is <strong>zero JavaScript.</strong> Adding <code>class="dark"</code> to the <code>&lt;html&gt;</code> element swaps every CSS variable. Tailwind reads the updated variables and re-renders every component. There's no context provider and no <code>useTheme</code> hook needed for the CSS layer — just a class toggle on the root element.</p>
<p><strong>Lesson:</strong> swap your color tokens to OKLCH. When defining dark mode values, adjust the first OKLCH parameter (lightness) and the result will look predictably lighter or darker. With hex or HSL you're often guessing; with OKLCH you're reasoning.</p>
<h2 id="heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</h2>
<p>The main page file is 16 lines:</p>
<pre><code class="language-tsx">// app/page.tsx
import Hero from "@/components/Blocks/Hero";
import { LogoCarousel } from "@/components/Blocks/LogoCarousel";
import { FeatureSection } from "@/components/Blocks/FeatureSection";
import { TeamSection } from "@/components/Blocks/TeamSection";
import { TestimonialSection } from "@/components/Blocks/TestimonialSection";
import { PricingSection } from "@/components/Blocks/PricingSection";
import { FaqSection } from "@/components/Blocks/FaqSection";

export default function Home() {
  return (
    &lt;main className="min-h-screen bg-white dark:bg-black"&gt;
      &lt;div className="mx-auto max-w-7xl px-6 pt-40"&gt;
        &lt;Hero /&gt;
        &lt;LogoCarousel /&gt;
        &lt;FeatureSection /&gt;
        &lt;TeamSection /&gt;
        &lt;TestimonialSection /&gt;
        &lt;PricingSection /&gt;
        &lt;FaqSection /&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>No dynamic imports, no lazy-loading config, no context providers wrapping everything. Each section is a completely self-contained component in <code>components/Blocks/</code>. None of them import from each other.</p>
<p>This decision came from watching how developers actually use <a href="https://www.shadcndeck.com/templates">shadcn templates</a>. The first thing anyone does after cloning is delete the sections they don't need and reorder the ones they keep. With flat imports, removing the Team section is one deleted line. Reordering sections is moving one line. Adding a new section is creating a file and adding one import.</p>
<p>The alternative (a sections array, a renderer loop, a config file that controls order) sounds sophisticated. In practice, it adds indirection that makes the template harder to understand and slower to customize. Templates should be obvious, not impressive.</p>
<p><strong>Lesson:</strong> in a template context, the simplest architecture is the correct architecture. The developer cloning your template isn't impressed by abstraction. They want to understand the code fast and change it faster.</p>
<h2 id="heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</h2>
<p>The Hero section uses entrance animations where each element fades up sequentially: badge first, then heading, then subheading, then CTA. The naïve approach sets a different <code>delay</code> prop on each element manually. The correct approach uses <code>staggerChildren</code>:</p>
<pre><code class="language-tsx">// components/Blocks/Hero.tsx
"use client"
import { motion, type Variants } from "motion/react"

const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.15,  // each child animates 150ms after the previous
      delayChildren: 0.1,
    },
  },
}

const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.5, ease: "easeOut" },
  },
}

const Hero = () =&gt; (
  &lt;motion.div variants={containerVariants} initial="hidden" animate="visible"&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* Badge */}
    &lt;/motion.div&gt;
    &lt;motion.h1 variants={fadeUpVariants}&gt;
      AI Chatbot for Customer Support.
    &lt;/motion.h1&gt;
    &lt;motion.p variants={fadeUpVariants}&gt;
      {/* Subheading */}
    &lt;/motion.p&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* CTA */}
    &lt;/motion.div&gt;
  &lt;/motion.div&gt;
)
</code></pre>
<p>The parent defines <code>staggerChildren: 0.15</code>. Every child with <code>variants={fadeUpVariants}</code> automatically inherits a 150ms offset from the previous child. Want to add a new element? Give it <code>variants={fadeUpVariants}</code> and the stagger chain extends automatically. No manually updated delay values.</p>
<p>The Features section uses <strong>scroll-triggered animations</strong> with a different easing:</p>
<pre><code class="language-tsx">// components/Blocks/FeatureSection.tsx
&lt;motion.div
  initial={{ opacity: 0, y: 40 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, amount: 0.3 }}
  transition={{
    duration: 0.5,
    delay: index * 0.15,
    ease: [0.22, 1, 0.36, 1],
  }}
&gt;
</code></pre>
<p><code>viewport={{ once: true }}</code> fires the animation once when the element enters the viewport, not on every scroll pass. <code>amount: 0.3</code> starts the animation when 30% of the element is visible, not when the full element is on screen. The cubic bezier <code>[0.22, 1, 0.36, 1]</code> is a fast-out-slow-in curve that feels physical rather than mechanical.</p>
<p><strong>Quick note on the import:</strong> most of the core API is compatible, but <code>motion/react</code> isn't a straight drop-in rename of <code>framer-motion</code>. If you're upgrading an existing project, check the <a href="https://motion.dev/docs/react-upgrade-guide">official migration guide</a> before swapping the import. Layout animations, <code>AnimatePresence</code> behaviour, and some hooks changed.</p>
<p><strong>Lesson:</strong> define animation variants at the parent level and use <code>staggerChildren</code> to orchestrate the sequence. Never set <code>delay</code> manually on individual elements — that creates a brittle list of numbers you have to update every time you add or remove an element. Let the parent handle timing; let children just declare what they animate to.</p>
<h2 id="heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</h2>
<p>The testimonials use a dual-row auto-scrolling marquee. The second row scrolls in reverse. There's no third-party marquee package. It's a small component built entirely on CSS animations defined in Tailwind v4's <code>@theme</code> block.</p>
<pre><code class="language-tsx">// components/ui/marquee.tsx
export function Marquee({
  reverse = false,
  pauseOnHover = false,
  vertical = false,
  children,
  repeat = 4,
  ...props
}) {
  return (
    &lt;div className="group flex gap-(--gap) overflow-hidden [--duration:40s] [--gap:2rem]"&gt;
      {Array(repeat).fill(0).map((_, i) =&gt; (
        &lt;div
          key={i}
          className={cn("flex shrink-0 justify-around gap-(--gap)", {
            "animate-marquee flex-row": !vertical,
            "group-hover:paused": pauseOnHover,
            "[animation-direction:reverse]": reverse,
          })}
        &gt;
          {children}
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  )
}
</code></pre>
<p>The <code>repeat={4}</code> prop renders the children 4 times side by side. As the CSS animation scrolls the container left, the repetitions create a seamless loop. By the time the first set has scrolled off screen, the second set is already in position.</p>
<p><code>group-hover:paused</code> is Tailwind applying <code>animation-play-state: paused</code> when the parent has <code>group</code> class and is hovered. No <code>onMouseEnter</code>/<code>onMouseLeave</code> handlers or state, just pure CSS.</p>
<p>To customize the scroll speed without touching the component source, you override the CSS variable inline:</p>
<pre><code class="language-tsx">&lt;Marquee pauseOnHover className="[--duration:20s]"&gt;
  {items.map(item =&gt; &lt;Card key={item.id} {...item} /&gt;)}
&lt;/Marquee&gt;
</code></pre>
<p><code>[--duration:20s]</code> is a Tailwind arbitrary property. It sets <code>--duration</code> directly on the element, which the animation reads via <code>var(--duration)</code>. Speed customization without a prop, without touching the component.</p>
<p><strong>Lesson:</strong> before reaching for a third-party animation package, check whether a CSS keyframe animation and a couple of Tailwind utilities can do the same job. A marquee, a fade loop, a pulsing skeleton — all of these are achievable with native CSS. Fewer dependencies means fewer breaking changes when the ecosystem moves.</p>
<h2 id="heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</h2>
<p>The pricing section has three cards: Free, Pro, and Business. Each card has four rows: plan name, price, CTA button, and features list. The features list height varies between plans. Without CSS subgrid, the rows don't align across cards.</p>
<p>The common workaround is <code>min-height</code> on each row, or JavaScript that measures each card and sets explicit heights. Both approaches are fragile. Subgrid solves it in CSS:</p>
<pre><code class="language-tsx">// components/Blocks/PricingSection.tsx
&lt;div className="grid lg:grid-cols-3"&gt;
  {plans.map((plan) =&gt; (
    &lt;div className="p-8 grid grid-rows-subgrid row-span-4 gap-6"&gt;
      &lt;div&gt;{/* Plan name + description */}&lt;/div&gt;
      &lt;div&gt;{/* Price */}&lt;/div&gt;
      &lt;div&gt;{/* CTA button */}&lt;/div&gt;
      &lt;div&gt;{/* Features list */}&lt;/div&gt;
    &lt;/div&gt;
  ))}
&lt;/div&gt;
</code></pre>
<p><code>grid-rows-subgrid</code> tells each card to participate in the parent grid's row tracks rather than creating its own. Each card spans 4 rows (<code>row-span-4</code>). The plan name row, price row, CTA row, and features row align across all three cards (regardless of content height) because they're all on the same row tracks.</p>
<p>Each card's <code>row-span-4</code> reserves four rows in the parent's implicit grid. Because every card spans the same four shared row tracks, their internal rows align automatically even though the parent never declares explicit row heights.</p>
<p>CSS subgrid has been in all modern browsers since late 2023. There's no reason to reach for a JavaScript layout solution when the platform handles it.</p>
<p><strong>Lesson:</strong> when you have a grid of cards where each card has multiple internal rows that need to align across columns, reach for <code>grid-rows-subgrid</code> before reaching for <code>min-height</code> or JavaScript. Define the number of rows each card spans with <code>row-span-N</code>, and the browser handles the rest.</p>
<h2 id="heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</h2>
<p>The logo carousel renders 12 brand logos: Shopify, Stripe, GitHub, Google, and others. The first instinct is to use a package like <code>react-icons</code> or <code>simple-icons</code>. I went a different direction: inline SVG paths stored as a plain TypeScript object.</p>
<pre><code class="language-tsx">// components/Blocks/LogoCarousel.tsx
const iconMap = {
  stripe: "M13.976 9.15c-2.172-.806...",
  github: "M12 .297c-6.63 0-12...",
  google: "M12.48 10.92v3.28h7.84...",
  // ...
} as const

const SimpleIcon = ({ iconSlug, size = 24 }: { iconSlug: string; size?: number }) =&gt; {
  const iconPath = iconMap[iconSlug as keyof typeof iconMap]
  return (
    &lt;svg role="img" viewBox="0 0 24 24" className="fill-black dark:fill-white"&gt;
      &lt;path d={iconPath} /&gt;
    &lt;/svg&gt;
  )
}
</code></pre>
<p>The <code>fill-black dark:fill-white</code> class means every logo automatically inverts in dark mode: no separate dark mode logo assets, and no conditional rendering based on theme.</p>
<p>The carousel itself duplicates the logo array to create a seamless loop:</p>
<pre><code class="language-tsx">{/* First pass */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`first-${i}`} {...company} /&gt;)}
{/* Second pass — identical, creates the seamless loop */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`second-${i}`} {...company} /&gt;)}
</code></pre>
<p>The CSS animation (<code>animate-logo-scroll</code>) scrolls the container left. When the first pass disappears off the left edge, the second pass is already in position. The loop is seamless.</p>
<p><strong>The trade-off:</strong> maintaining SVG paths manually is fine for a fixed set of logos. If you need a large dynamic icon set, reach for <code>simple-icons</code> or a proper icon library. For 12 brand logos that rarely change, this approach ships zero extra dependencies.</p>
<p><strong>Lesson:</strong> match your tooling to your actual requirements. A logo carousel with a fixed set of brand logos doesn't need an icon library — it needs a TypeScript object and two Tailwind classes. Installing a package to solve a problem you could solve with 10 lines of code adds maintenance surface for no gain.</p>
<h2 id="heading-what-id-do-differently">What I'd Do Differently</h2>
<p>These are the three decisions I'd change if starting the template today.</p>
<h3 id="heading-1-extract-animation-variants-to-a-shared-file">1. Extract Animation Variants to a Shared File</h3>
<p><code>containerVariants</code> and <code>fadeUpVariants</code> are currently defined locally in both <code>Hero.tsx</code> and <code>FeatureSection.tsx</code>. If you want to change the global animation timing (say, reduce duration from 0.5s to 0.3s) you update two files. A shared <code>lib/animations.ts</code> exporting the standard variants would make global timing changes a one-line edit.</p>
<pre><code class="language-ts">// lib/animations.ts
export const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
}

export const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { staggerChildren: 0.15, delayChildren: 0.1 } },
}
</code></pre>
<h3 id="heading-2-use-subgrid-in-the-features-section-too">2. Use Subgrid in the Features Section Too</h3>
<p>The Features grid uses a border-based visual separation pattern — borders between cells create the grid appearance. It works, but the hover states have an inconsistency: the gradient hover overlay height varies slightly between cells in the same row because content heights differ. Subgrid would lock those row heights across cards the same way it does in the Pricing section.</p>
<h3 id="heading-3-use-nextfont-more-consistently">3. Use <code>next/font</code> More Consistently</h3>
<p>The layout loads both Geist and Inter font families. Inter is used via <code>--font-sans</code>. Geist is loaded but the <code>geistSans.variable</code> and <code>geistMono.variable</code> are applied to <code>&lt;body&gt;</code> as className strings while Inter drives the actual font rendering through the CSS variable. The result is that Geist is loaded but not actually displayed. Cleaning this up could shave tens of kilobytes from the font payload — worth verifying in Lighthouse or the Network tab before deploying.</p>
<h2 id="heading-summary">Summary</h2>
<p>These are the five things from this build worth taking into your next project:</p>
<ol>
<li><p><strong>shadcn/ui's</strong> <code>base-nova</code> <strong>style</strong> runs on Base UI primitives. ARIA state drives visual state — no parallel JavaScript state needed.</p>
</li>
<li><p><strong>Tailwind v4 moves theme config to CSS.</strong> All theme tokens, custom animations, and radius scales live in CSS via <code>@theme inline</code>. This is the right place for them.</p>
</li>
<li><p><strong>OKLCH gives predictable dark mode contrast.</strong> Adjusting lightness in OKLCH actually changes perceived brightness. Hex and HSL don't guarantee this.</p>
</li>
<li><p><code>staggerChildren</code> <strong>in motion/react</strong> eliminates manually managed animation delays. The parent orchestrates while the children just declare their animation variant.</p>
</li>
<li><p><strong>CSS subgrid (</strong><code>grid-rows-subgrid</code><strong>)</strong> aligns card rows across columns natively. No JavaScript measurement, no fixed heights.</p>
</li>
</ol>
<p>The full template is MIT-licensed and available at <a href="https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template">github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template</a>. If it's useful, a star helps others find it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ A Guide to Modern React Form Architecture: TanStack Form + Zod + Shadcn ]]>
                </title>
                <description>
                    <![CDATA[ Building production-grade forms in React can be a painful experience. It's one of the parts of front-end engineering that most developers are uncomfortable with. Usually, you'll start with a simple co ]]>
                </description>
                <link>https://www.freecodecamp.org/news/a-guide-to-modern-react-form-architecture-tanstack-form-zod-shadcn/</link>
                <guid isPermaLink="false">6a68d8cf34380fc31276ad8a</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ forms ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tapas Adhikary ]]>
                </dc:creator>
                <pubDate>Tue, 28 Jul 2026 16:29:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9383f6c8-b938-4540-83cc-42745cdbc943.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Building production-grade forms in React can be a painful experience. It's one of the parts of front-end engineering that most developers are uncomfortable with.</p>
<p>Usually, you'll start with a simple controlled form using the <code>useState</code> hook. But as the form grows, you realise that typing a single character into an input field triggers a re-render of the entire component tree. The application becomes sluggish and ends up creating a terrible user experience.</p>
<p>You might even try mitigating this re-rendering issue by switching to an uncontrolled form using the <code>useRef</code> hook. But this introduces horrible scalability issues, and as the form grows, maintaining the code becomes a nightmare.</p>
<p>Vibe coding might get you to a working demo, but actual engineering gets you to production. To fix these performance and scalability issues in the production apps, we need to rethink how inputs work.</p>
<p>In this article, we'll build a production-ready form architecture. We'll use <code>TanStack Form</code> as a headless state machine to solve the performance and scalability problems, <code>Zod</code> for bulletproof validations, and <code>ShadCN UI</code> for accessible, beautiful components.</p>
<p>If you're a visual learner, I've recorded a full masterclass video covering this exact architecture over on my YouTube channel, <a href="https://www.youtube.com/tapasadhikary">tapaScript</a>. You can watch it right here:</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/qSR6UeSKnT0" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-problem-with-traditional-forms">The Problem with Traditional Forms</a></p>
<ul>
<li><p><a href="#heading-the-uncontrolled-ref-workaround">The Uncontrolled "Ref" Workaround</a></p>
</li>
<li><p><a href="#heading-why-not-just-use-native-html-forms">Why Not Just Use Native HTML Forms?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-introducing-tanstack-form">Introducing TanStack Form</a></p>
</li>
<li><p><a href="#heading-fine-grained-reactivity">Fine-Grained Reactivity</a></p>
</li>
<li><p><a href="#heading-validation-with-zod">Validation with Zod</a></p>
</li>
<li><p><a href="#heading-headless-tanstack-form-with-shadcn-ui">Headless TanStack Form with ShadCN UI</a></p>
</li>
<li><p><a href="#heading-handling-nested-dynamic-arrays-with-tanstack-form">Handling Nested Dynamic Arrays with TanStack Form</a></p>
</li>
<li><p><a href="#heading-tanstack-forms-granular-reactivity-useselector-vs-formsubscribe">TanStack Form's Granular Reactivity: <code>useSelector</code> vs <code>form.Subscribe</code></a></p>
<ul>
<li><p><a href="#heading-with-useselector">With <code>useSelector()</code></a></p>
</li>
<li><p><a href="#heading-with-formsubscribe">With <code>form.Subscribe()</code></a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-tanstack-form-vs-react-hook-form">TanStack Form vs. React Hook Form</a></p>
<ul>
<li><p><a href="#heading-dynamic-arrays">Dynamic Arrays</a></p>
</li>
<li><p><a href="#heading-deeply-nested-components">Deeply Nested Components</a></p>
</li>
<li><p><a href="#id=%22heading-the-ecosystem-sync%22">The Ecosystem Sync</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion-amp-important-resources">Conclusion &amp; Important Resources</a></p>
</li>
<li><p><a href="#heading-if-youve-read-this-far">If You've Read This Far...</a></p>
</li>
</ol>
<h2 id="heading-the-problem-with-traditional-forms">The Problem with Traditional Forms</h2>
<p>When developers realize that <code>useState</code> gives them the control to manage forms using React's state, they often create form components like this:</p>
<pre><code class="language-typescript">import { useState } from "react";

export function StandardRegistrationForm() {
 
  const [formData, setFormData] = useState({
    firstName: "",
    lastName: "",
    email: "",
    password: "",
  });

  console.log("Form Re-rendered! Current state:", formData.firstName);

  const handleChange = (e: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    setFormData((prev) =&gt; ({
      ...prev,
      [e.target.name]: e.target.value,
    }));
  };

  return (
    &lt;div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md mx-auto mt-10 text-white"&gt;
      &lt;div className="mb-6"&gt;
        &lt;h2 className="text-xl font-bold text-red-500"&gt;The Problem&lt;/h2&gt;
        &lt;p className="text-sm text-neutral-400"&gt;Standard Controlled Form&lt;/p&gt;
      &lt;/div&gt;

      &lt;form className="space-y-4"&gt;
        &lt;div className="flex flex-col gap-2"&gt;
          &lt;label className="text-sm font-medium text-neutral-300"&gt;
            First Name
          &lt;/label&gt;
          &lt;input
            name="firstName"
            value={formData.firstName}
            onChange={handleChange}
            className="p-2 rounded-md bg-neutral-950 border border-neutral-700 focus:border-red-500 focus:outline-none"
          /&gt;
        &lt;/div&gt;

        &lt;div className="flex flex-col gap-2"&gt;
          &lt;label className="text-sm font-medium text-neutral-300"&gt;
            Last Name
          &lt;/label&gt;
          &lt;input
            name="lastName"
            value={formData.lastName}
            onChange={handleChange}
            className="p-2 rounded-md bg-neutral-950 border border-neutral-700 focus:border-red-500 focus:outline-none"
          /&gt;
        &lt;/div&gt;

        &lt;div className="flex flex-col gap-2"&gt;
          &lt;label className="text-sm font-medium text-neutral-300"&gt;
            Email
          &lt;/label&gt;
          &lt;input
            name="email"
            type="email"
            value={formData.email}
            onChange={handleChange}
            className="p-2 rounded-md bg-neutral-950 border border-neutral-700 focus:border-red-500 focus:outline-none"
          /&gt;
        &lt;/div&gt;

        &lt;button
          type="button"
          className="w-full mt-4 bg-neutral-800 text-neutral-400 py-2 rounded-md font-bold cursor-not-allowed"
        &gt;
          Sign Up
        &lt;/button&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The component uses a centralized <code>useState</code> object, which is the standard way most developers are taught to build forms. The moment a value changes for any input fields, the state gets updated.</p>
<p>In React, every time you update a state, the component re-renders to display the current state value. This introduces a performance issue that you need to solve.</p>
<p>The image below shows the exact performance problem of the form component getting re-rendered with every keystroke inside the input elements.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5c9bb4026656f09759cdc1f0/e80e63f0-96c3-4a7d-bd39-6c13ccc71282.gif" alt="The component re-rendering" style="display:block;margin:0 auto" width="1228" height="1158" loading="lazy">

<h3 id="heading-the-uncontrolled-ref-workaround">The Uncontrolled "Ref" Workaround</h3>
<p>Acknowledging the problem we've seen with the controlled form component, devs found a workaround. Instead of using state, if we use ref, the components don't re-render on ref's current value changes. That sounds like an AHA! moment until we figure out that it introduces completely different architectural headaches.</p>
<p>Here's the same form, but managed using refs instead of states:</p>
<pre><code class="language-typescript">import { useRef } from "react";

export function RefRegistrationForm() {
 
  const firstNameRef = useRef&lt;HTMLInputElement&gt;(null);
  const lastNameRef = useRef&lt;HTMLInputElement&gt;(null);
  const emailRef = useRef&lt;HTMLInputElement&gt;(null);

  console.log("Form Rendered (Notice it doesn't log when typing)");

  const handleSubmit = (e: React.FormEvent) =&gt; {
    e.preventDefault();

    const formData = {
      firstName: firstNameRef.current?.value,
      lastName: lastNameRef.current?.value,
      email: emailRef.current?.value,
    };

    console.log("Submitted Data:", formData);

  };

  return (
    &lt;div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md mx-auto mt-10 text-white"&gt;
      &lt;div className="mb-6"&gt;
        &lt;h2 className="text-xl font-bold text-orange-500"&gt;The Ref Workaround&lt;/h2&gt;
        &lt;p className="text-sm text-neutral-400"&gt;Fast, but scales horribly.&lt;/p&gt;
      &lt;/div&gt;

      &lt;form onSubmit={handleSubmit} className="space-y-4"&gt;
        &lt;div className="flex flex-col gap-2"&gt;
          &lt;label className="text-sm font-medium text-neutral-300"&gt;
            First Name
          &lt;/label&gt;
          &lt;input
            name="firstName"
            ref={firstNameRef} // Attaching the ref
            className="p-2 rounded-md bg-neutral-950 border border-neutral-700 focus:border-orange-500 focus:outline-none"
          /&gt;
        &lt;/div&gt;

        &lt;div className="flex flex-col gap-2"&gt;
          &lt;label className="text-sm font-medium text-neutral-300"&gt;
            Last Name
          &lt;/label&gt;
          &lt;input
            name="lastName"
            ref={lastNameRef} // Attaching the ref
            className="p-2 rounded-md bg-neutral-950 border border-neutral-700 focus:border-orange-500 focus:outline-none"
          /&gt;
        &lt;/div&gt;

        &lt;div className="flex flex-col gap-2"&gt;
          &lt;label className="text-sm font-medium text-neutral-300"&gt;
            Email
          &lt;/label&gt;
          &lt;input
            name="email"
            type="email"
            ref={emailRef} // Attaching the ref
            className="p-2 rounded-md bg-neutral-950 border border-neutral-700 focus:border-orange-500 focus:outline-none"
          /&gt;
        &lt;/div&gt;

        &lt;button
          type="submit"
          className="w-full mt-4 bg-orange-600 hover:bg-orange-500 py-2 rounded-md font-bold transition-colors"
        &gt;
          Sign Up
        &lt;/button&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Let's talk about the problems with this approach:</p>
<ul>
<li><p>The Ref Soup: We've created a separate ref for every single input. Imagine this form grows to 20+ fields, and you have to manage all those refs!</p>
</li>
<li><p>We need to extract the data manually and stitch the object back together. There's no mechanism like <code>setState()</code> that exists here.</p>
</li>
<li><p>How do we do real-time validation? Since the field/component doesn't re-render here as you type, showing a dynamic error message under the inputs becomes incredibly hard without introducing new state variables, and doing that would defeat the whole purpose of refs.</p>
</li>
</ul>
<h3 id="heading-why-not-just-use-native-html-forms">Why Not Just Use Native HTML Forms?</h3>
<p>Oh yes! We shouldn't ignore this possibility as well. Why use a library at all? Can't we just use native HTML forms and grab the data with FormData on submit?</p>
<p>If you're building a simple "Contact Us" or "Login Form", the native HTML forms are actually the best tool for the job. They're lean, require zero library knowledge, and are incredibly efficient.</p>
<p>But for complex, production-grade web applications, the native approach quickly hits a wall:</p>
<ol>
<li><p>Complex data structures: <code>FormData</code> is a flat object. If you're dealing with deeply nested objects or dynamic arrays, manually parsing <code>speakers[0][firstName]</code> out of a flat FormData object could be messy.</p>
</li>
<li><p>Dynamic UI: If you have a requirement where Field B should appear if Field A is set to "Yes", you need client-side state to track Field A's value anyway. FormData only exists at the exact moment of submission.</p>
</li>
<li><p>UX &amp; Real-time Validations: Native HTML validations are rigid and hard to style cleanly across browsers. If you need complex cross-field validations (like confirming passwords match) or async validations (checking if a username is taken while the user types), you need JavaScript.</p>
</li>
</ol>
<p>When native forms aren't enough, we move to React.</p>
<h2 id="heading-introducing-tanstack-form">Introducing TanStack Form</h2>
<p><a href="https://tanstack.com/form/latest">TanStack Form</a> is a headless state machine for your inputs. It gives you the strict typing and easy data management of a controlled form, but uses fine-grained reactivity to ensure that only the specific field you're typing in re-renders, and nothing else.</p>
<p>Let's set it up in a modern Vite + TypeScript environment. First, install the TanStack Form using this command:</p>
<pre><code class="language-shell">npm install @tanstack/react-form # Or use, equivallent yarn, pnpm commands
</code></pre>
<p>Instead of a massive list of individual state hooks, you just need the <code>useForm</code> hook to spin up the form engine:</p>
<pre><code class="language-typescript">import { useForm } from '@tanstack/react-form';

export function SpeakerForm() {
  const form = useForm({
    defaultValues: {
      firstName: '',
      lastName: '',
      twitterHandle: '',
    },
    onSubmit: async ({ value }) =&gt; {
      console.log('Form Submitted!', value);
    },
  });

  return (
    &lt;div className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 max-w-md mx-auto text-white"&gt;
      &lt;h2 className="text-xl font-bold mb-6"&gt;Add New Speaker&lt;/h2&gt;
      {/* We will build the fields here */}
    &lt;/div&gt;
  );
}
</code></pre>
<p>Notice the <code>defaultValues</code>. TanStack Form automatically infers your entire form structure from this object. If you try to reference an invalid field name later, TypeScript will throw an error immediately.</p>
<h2 id="heading-fine-grained-reactivity">Fine-Grained Reactivity</h2>
<p>Let's now connect the form inputs to the engine. To do that, we need to wrap them in TanStack's <code>Field</code> component. This uses the standard JSX <a href="https://www.youtube.com/watch?v=tIdJj0n1mg4">render prop pattern</a> to pass the isolated field state directly into our UI.</p>
<pre><code class="language-typescript">&lt;form
  onSubmit={(e) =&gt; {
    e.preventDefault();
    e.stopPropagation();
    form.handleSubmit();
  }}
  className="space-y-4"
&gt;
  &lt;form.Field name="firstName"&gt;
    {(field) =&gt; {
      // PROOF: This log will only fire when firstName changes!
      console.log("Rendering First Name Field");
      
      return (
        &lt;div className="flex flex-col gap-2"&gt;
          &lt;label htmlFor={field.name} className="text-sm text-neutral-300"&gt;
            First Name
          &lt;/label&gt;
          &lt;input
            id={field.name}
            name={field.name}
            value={field.state.value}
            onBlur={field.handleBlur}
            onChange={(e) =&gt; field.handleChange(e.target.value)}
            className="p-2 rounded-md bg-neutral-950 border border-neutral-700 focus:border-emerald-500 outline-none"
          /&gt;
        &lt;/div&gt;
      );
    }}
  &lt;/form.Field&gt;
  
  &lt;button type="submit" className="w-full mt-4 bg-emerald-600 py-2 rounded-md font-bold"&gt;
    Save Speaker
  &lt;/button&gt;
&lt;/form&gt;
</code></pre>
<p>If you place a <code>console.log</code> in the parent component and one inside the field, you'll see the magic. When you type in the First Name input, the parent component doesn't re-render. Here's a visual of only the form field re-rendering.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5c9bb4026656f09759cdc1f0/0ffbaa73-0d42-41d7-bbb3-eeb12e51ad62.gif" alt="TanStack Form" style="display:block;margin:0 auto" width="1134" height="628" loading="lazy">

<p>The state update is strictly isolated to the function inside that specific <code>Field</code> component.</p>
<h2 id="heading-validation-with-zod">Validation with Zod</h2>
<p>A form is only useful if it has field validations. In the current ecosystem, <a href="https://zod.dev/">Zod</a> is the industry standard. First, install Zod and the zod form adapter.</p>
<pre><code class="language-shell">npm install zod @tanstack/zod-form-adapter # Or use, equivallent yarn, pnpm commands
</code></pre>
<p>Then inject the Zod adapter into your form engine:</p>
<pre><code class="language-typescript">import { zodValidator } from '@tanstack/zod-form-adapter';
import { z } from 'zod';

// Inside your component...
const form = useForm({
  defaultValues: { firstName: '', lastName: '' },
  validatorAdapter: zodValidator(),
  onSubmit: async ({ value }) =&gt; { /* ... */ },
});
</code></pre>
<p>Next, we'll update our field to use Zod rules. Here we opted for a rule that the first name must be at least 2 characters. Also, by checking the <code>field.state.meta.isTouched</code>, we ensure the error only shows after the user interacts with the input.</p>
<pre><code class="language-typescript">
&lt;form.Field
  name="firstName"
  validators={{
    onChange: z.string().min(2, "First name must be at least 2 characters"),
  }}
&gt;
  {(field) =&gt; (
    &lt;div className="flex flex-col gap-2"&gt;
      &lt;label htmlFor={field.name} className="text-neutral-300"&gt;
        First Name
      &lt;/label&gt;
      
      &lt;input
        id={field.name}
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) =&gt; field.handleChange(e.target.value)}
        className="bg-neutral-950 border-neutral-700 focus-visible:ring-emerald-500"
      /&gt;
      {field.state.meta.isTouched &amp;&amp; field.state.meta.errors.length ? (
        &lt;em className="text-red-500 text-xs"&gt;
        {
           field.state.meta.errors.map((error) =&gt;  error.message)
         }
        &lt;/em&gt;) : null}
    
    &lt;/div&gt;
  )}
&lt;/form.Field&gt;
</code></pre>
<p>The image below shows how the error message appears below the field when the First Name field value is changed and it fails validation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5c9bb4026656f09759cdc1f0/13ec370d-68d9-450e-badc-5a0269816231.png" alt="Form Validation" style="display:block;margin:0 auto" width="1135" height="678" loading="lazy">

<h2 id="heading-headless-tanstack-form-with-shadcn-ui">Headless TanStack Form with ShadCN UI</h2>
<p>TanStack Form is headless. This means you can map it to any design system seamlessly. Let's take one of the modern design systems available today: <a href="https://ui.shadcn.com/">ShadCN UI</a>.</p>
<p>If you are using a Vite-TypeScript-based React project, you can integrate the ShadCN UI in a few simple steps:</p>
<p>First, open the <code>tsconfig.json</code> file and add this compiler option:</p>
<pre><code class="language-json">"compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  }
</code></pre>
<p>Then open the <code>vite.config.json</code> file and add the mapping for the <code>@</code> alias:</p>
<pre><code class="language-typescript">import react from '@vitejs/plugin-react'
import path from "path"
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
})
</code></pre>
<p>Install shadcn and its components like label, input, and button:</p>
<pre><code class="language-shell">npx shadcn@latest add input label button
</code></pre>
<p>Now, you can import these ShadCN components into your form component and replace the native HTML label, input, and button.</p>
<pre><code class="language-typescript">import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

&lt;form.Field
  name="firstName"
  validators={{
    onChange: z.string().min(2, "First name must be at least 2 characters"),
  }}
&gt;
  {(field) =&gt; (
    &lt;div className="flex flex-col gap-2"&gt;
      &lt;Label htmlFor={field.name} className="text-neutral-300"&gt;
        First Name
      &lt;/Label&gt;
      
      &lt;Input
        id={field.name}
        name={field.name}
        value={field.state.value}
        onBlur={field.handleBlur}
        onChange={(e) =&gt; field.handleChange(e.target.value)}
        className="bg-neutral-950 border-neutral-700 focus-visible:ring-emerald-500"
      /&gt;
      
       {field.state.meta.isTouched &amp;&amp; field.state.meta.errors.length ? (
        &lt;em className="text-red-500 text-xs"&gt;
        {
           field.state.meta.errors.map((error) =&gt;  error.message)
         }
        &lt;/em&gt;) : null}
    
    &lt;/div&gt;
  )}
&lt;/form.Field&gt;
</code></pre>
<h2 id="heading-handling-nested-dynamic-arrays-with-tanstack-form">Handling Nested Dynamic Arrays with TanStack Form</h2>
<p>Suppose we're building a speaker form for a meetup. What if we want to feature multiple speakers for a talk in our meetup? Now we need to manage a data structure to hold more than one speaker's details. We'll need an array of objects.</p>
<p>In classic React, managing an array of objects with complex states leads to spaghetti code. TanStack Form treats arrays exactly like a typical form field. We just need to update our <code>defaultValues</code> to an array:</p>
<pre><code class="language-typescript">defaultValues: {
  speakers: [ { firstName: '', lastName: '' } ],
},
</code></pre>
<p>Now, set the mode to <code>array</code> on your field and map over it:</p>
<pre><code class="language-typescript">&lt;form.Field name="speakers" mode="array"&gt;
  {(field) =&gt; (
    &lt;div className="space-y-6"&gt;
      {field.state.value.map((_, index) =&gt; (
        &lt;div key={index} className="p-4 border border-neutral-800 rounded-lg relative"&gt;
          
          &lt;div className="grid grid-cols-2 gap-4"&gt;
            {/* Nested Fields use bracket notation! */}
            &lt;form.Field name={`speakers[${index}].firstName`}&gt;
              {(subField) =&gt; (
                 &lt;div&gt;
                    &lt;Label&gt;First Name&lt;/Label&gt;
                    &lt;Input 
                      value={subField.state.value} 
                      onChange={(e) =&gt; subField.handleChange(e.target.value)} 
                    /&gt;
                 &lt;/div&gt;
              )}
            &lt;/form.Field&gt;
          &lt;/div&gt;

          {/* Remove Button */}
          {field.state.value.length &gt; 1 &amp;&amp; (
            &lt;Button 
              type="button" 
              variant="destructive" 
              onClick={() =&gt; field.removeValue(index)}
            &gt;
              Remove
            &lt;/Button&gt;
          )}
        &lt;/div&gt;
      ))}
      
      {/* Add Button */}
      &lt;Button
        type="button"
        variant="outline"
        onClick={() =&gt; field.pushValue({ firstName: '', lastName: '' })}
      &gt;
        + Add Speaker
      &lt;/Button&gt;
    &lt;/div&gt;
  )}
&lt;/form.Field&gt;
</code></pre>
<p>To append data, we call <code>field.pushValue()</code> and to remove, we call <code>field.removeValue(index)</code>. Typing into the newest row leaves the previous array fields completely static. It's incredibly performant.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5c9bb4026656f09759cdc1f0/2a5552f6-2103-441f-ac47-758796985eae.png" alt="Array of Object" style="display:block;margin:0 auto" width="2315" height="1068" loading="lazy">

<p>The image above shows the multi-speaker form and its output when submitted.</p>
<h2 id="heading-tanstack-forms-granular-reactivity-useselector-vs-formsubscribe">TanStack Form's Granular Reactivity: <code>useSelector</code> vs <code>form.Subscribe</code></h2>
<p>Sometimes, you need to access form state outside an input. For example, you want to show the "Unsaved Changes!" notification the moment the user changes the value in the input fields.</p>
<p>Notice that here the notification is outside the form, but it solely depends on the form field values.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5c9bb4026656f09759cdc1f0/e22c66a8-7ebc-40df-b598-fe91eb257182.png" alt="unsaved data" style="display:block;margin:0 auto" width="903" height="647" loading="lazy">

<p>TanStack gives you two distinct tools for this, and knowing when to use them is the key to mastering this architecture.</p>
<h3 id="heading-with-useselector">With <code>useSelector()</code></h3>
<p>If the state change affects the macro-layout of your page (like showing a global "unsaved Changes" banner like the above), you want the parent component to re-render. We'll use the standalone <code>useSelector()</code> hook for this:</p>
<pre><code class="language-typescript">import { useForm, useSelector } from "@tanstack/react-form";

export function ReactivityDemo() {
  const form = useForm({ defaultValues: { bio: "" } });

  // This isolates the exact piece of state we want the parent to track
  const isFormDirty = useSelector(form.store, (state) =&gt; state.isDirty);

  return (
    &lt;div className="relative"&gt;
      {isFormDirty &amp;&amp; (
        &lt;div className="absolute top-0 w-full bg-orange-500 text-center"&gt;
          Unsaved Changes!
        &lt;/div&gt;
      )}
      {/* Form goes here */}
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-with-formsubscribe">With <code>form.Subscribe()</code></h3>
<p>If the state change only affects a micro-interaction like disabling a submit button while the form is being saved, we don't want the whole form to re-render. In that case, we wrap the button tightly inside <code>&lt;form.Subscribe&gt;</code>.</p>
<pre><code class="language-typescript">&lt;form.Subscribe selector={(state) =&gt; [state.canSubmit, state.isSubmitting]}&gt;
  {([canSubmit, isSubmitting]) =&gt; (
    &lt;Button
      type="submit"
      disabled={!canSubmit || isSubmitting}
      className="w-full mt-4"
    &gt;
      {isSubmitting ? "Saving..." : "Save Changes"}
    &lt;/Button&gt;
  )}
&lt;/form.Subscribe&gt;
</code></pre>
<p>This isolates the re-render exclusively to the button. The rest of the component stays completely undisturbed.</p>
<h2 id="heading-tanstack-form-vs-react-hook-form">TanStack Form vs. React Hook Form</h2>
<p>If you've been building React applications for a while, you're probably thinking: "Why not just use React Hook Form?"</p>
<p><code>React Hook Form</code> is an extraordinary library and has been the industry standard for years. It solves the performance problem by leveraging uncontrolled components and <code>useRef</code> under the hood. It isolates re-renders perfectly and handles validation well.</p>
<p>But as applications scale in complexity, the TanStack Form architecture offers three distinct advantages over React Hook Form:</p>
<h3 id="heading-dynamic-arrays">Dynamic Arrays</h3>
<p>If you've ever built a complex form with React Hook Form that needs dynamic, nested arrays (like adding multiple speakers in our example above), you probably need a hook like <code>useFieldArray</code>. Managing complex useFieldArray implementations often needs huge boilerplate, careful index tracking, and jumping through to maintain strict TypeScript safety deep within the tree.</p>
<p>TanStack Form eliminates this. It treats arrays the same as a string input. The form is a centralized state machine: you simply inform a field that it has a <code>mode=array</code>, and you instantly get access to <code>pushValue()</code> and <code>removeValue()</code> along with deeply nested type-safety.</p>
<h3 id="heading-deeply-nested-components">Deeply Nested Components</h3>
<p>React Hook Form relies on uncontrolled inputs to achieve performance. While this is fast, passing refs around deeply nested component trees can get messy, especially when integrating with complex UI libraries.</p>
<p>TanStack Form gives us the strict, predictable architecture of a controlled component but uses fine-grained reactivity to ensure only the specific parts of the UI that change are re-rendered.</p>
<h3 id="heading-the-ecosystem-sync">The Ecosystem Sync</h3>
<p>If you're moving towards modern, production-ready stacks using TanStack Query (React Query) for data fetching and TanStack Router for navigation, TanStack Form shares the exact same mental model and family. It integrates seamlessly into the ecosystem, providing a unified DX across your entire application architecture.</p>
<h2 id="heading-conclusion-amp-important-resources">Conclusion &amp; Important Resources</h2>
<p>By combining TanStack Form, Zod, and ShadCN UI, we've created a strictly typed, reactive, production-ready form architecture that handles everything from basic text inputs to complex nested arrays without sacrificing any bit of performance.</p>
<p>You can grab the complete starter code and the final project from my GitHub:</p>
<ul>
<li><p>All the source code used in the article and the complete project source code: <a href="https://github.com/tapascript/full-stack-vibe-to-prod/tree/main/11-tanstack-form">https://github.com/tapascript/full-stack-vibe-to-prod/tree/main/11-tanstack-form</a></p>
</li>
<li><p>A code scaffolding repo for React projects using TypeScript, Vite, and TailwindCSS: <a href="https://github.com/atapas/code-react19-ts">https://github.com/atapas/code-react19-ts</a></p>
</li>
</ul>
<p>If you found this helpful, you'll find these two in-depth video tutorials helpful, too:</p>
<ul>
<li><p><a href="https://www.youtube.com/watch?v=9VnPRZ0F7yc">TanStack Router Crash Course</a></p>
</li>
<li><p><a href="https://www.youtube.com/watch?v=Hu1dtgK_CkU">TanStack Query With Projects</a></p>
</li>
</ul>
<h2 id="heading-if-youve-read-this-far"><strong>If You've Read This Far...</strong></h2>
<p>Thank You!</p>
<p>I'm thrilled to announce that I've started a <a href="https://www.youtube.com/playlist?list=PLIJrr73KDmRwySan3tObLmLZp0NYWSmCT">Full Stack FREE Course</a> to take developers from vibe coding to a production-ready mental model. I'd be delighted if you check it out and take part.</p>
<ul>
<li><p>Subscribe to my <a href="https://www.youtube.com/tapasadhikary?sub_confirmation=1">YouTube Channel</a></p>
</li>
<li><p>Follow on <a href="https://www.linkedin.com/in/tapasadhikary/">LinkedIn</a> and <a href="https://x.com/tapasadhikary">X</a></p>
</li>
<li><p>Catch up with my <a href="https://www.tapascript.io/books/react-clean-code-rule-book">React Clean Code Rules Book</a></p>
</li>
<li><p>All the source code used in this article is on my <a href="https://github.com/tapascript/full-stack-vibe-to-prod">GitHub Repository</a>.</p>
</li>
</ul>
<p>See you soon with my next article. Until then, please take care of yourself and keep learning.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Reusable Date-Time Picker in React with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ A date and time picker is one of those components that looks small in a design file and turns into a real time sink once you start building it. You need a calendar, a time selector, a state that keeps ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-reusable-date-time-picker-in-react-with-shadcn-ui/</link>
                <guid isPermaLink="false">6a60f42cb1ecbbb606535a7b</guid>
                
                    <category>
                        <![CDATA[ shadcn ui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 16:47:40 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/7dcddc1c-2a9d-4af7-8f02-ffb76bea2c7b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A date and time picker is one of those components that looks small in a design file and turns into a real time sink once you start building it. You need a calendar, a time selector, a state that keeps both in sync, and usually a range mode and a translated version somewhere down the line, too.</p>
<p>This guide walks through ready-made picker patterns you can drop into a React project today: a combined date and time picker, a date range picker, and a time picker.</p>
<p>Every one of these is available as a <a href="https://shadcnspace.com/components/date-picker"><strong>Shadcn Date Picker</strong></a> component you can install with a single CLI command instead of building from scratch.</p>
<p>These components are built on both Radix and Base UI primitives, and the versions below use Base UI. They also support copy-prompt functionality, so you can paste them straight into v0, Lovable, or Bolt if that's part of your workflow.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-how-to-install-a-shadcn-date-time-picker">How to Install a Shadcn Date Time Picker</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-date-and-time-picker">How to Build a Date and Time Picker</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-date-range-picker">How to Build a Date Range Picker</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-time-picker">How to Build a Time Picker</a></p>
</li>
<li><p><a href="#heading-live-preview-of-the-components">Live Preview of the components</a></p>
</li>
<li><p><a href="#heading-key-concepts-recap">Key Concepts Recap</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should know:</p>
<ul>
<li><p>The basics of React, including <code>useState</code> and props</p>
</li>
<li><p>How to install components with the shadcn/ui CLI</p>
</li>
<li><p>Basic Tailwind CSS class names</p>
</li>
</ul>
<p>You also need a React project with shadcn/ui already set up. If you haven't done that yet, run the shadcn/ui CLI setup command in your project before continuing.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<ul>
<li><p>A <code>DateTimePicker</code> component that combines a calendar and time slots into one value.</p>
</li>
<li><p>A <code>TimePicker</code> variant that reuses the same time-slot logic without a calendar.</p>
</li>
<li><p>A <code>DateRangePicker</code> that lets a user pick a start and end date.</p>
</li>
</ul>
<h2 id="heading-how-to-install-a-shadcn-date-time-picker"><strong>How to Install a Shadcn Date Time Picker</strong></h2>
<p>All the components below install through the same CLI pattern. Pick the package manager you use:</p>
<p><strong>pnpm</strong></p>
<pre><code class="language-javascript">pnpm dlx shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p><strong>npm</strong></p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p><strong>yarn</strong></p>
<pre><code class="language-javascript">yarn dlx shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p><strong>bun</strong></p>
<pre><code class="language-javascript">bunx --bun shadcn@latest add @shadcn-space/date-picker-01
</code></pre>
<p>Every other component below installs the same way: just swap the package name at the end of the command. If you haven't set up the CLI in your project yet, this <a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli"><strong>getting-started guide</strong></a> covers that first and shows how to integrate these components when you're working through an MCP-connected editor.</p>
<h2 id="heading-how-to-build-a-date-and-time-picker"><strong>How to Build a Date and Time Picker</strong></h2>
<p>This is the combined picker: a calendar popover for the date, plus start and end time fields, wrapped around a booking confirmation flow.</p>
<p><strong>Folder structure:</strong></p>
<pre><code class="language-javascript">components
└── shadcn-space
    └── date-picker
        └── date-picker-01.tsx
</code></pre>
<p><strong>Component code:</strong></p>
<pre><code class="language-javascript">"use client";
import { useState } from "react";
import { format } from "date-fns";
import { CalendarIcon, Clock, ChevronDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";

import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";

const DateAndTimePickerDemo = () =&gt; {
  const [open, setOpen] = useState(false);
  const [date, setDate] = useState&lt;Date | undefined&gt;(undefined);
  const [bookingStatus, setBookingStatus] = useState&lt;
    "idle" | "loading" | "success"
  &gt;("idle");

  const handleBooking = () =&gt; {
    setBookingStatus("loading");
    setTimeout(() =&gt; setBookingStatus("success"), 1500);
  };

  return (
    &lt;&gt;
      &lt;div className="grid gap-6"&gt;
        &lt;div className="grid gap-2"&gt;
          &lt;Label htmlFor="date" className="text-sm font-semibold"&gt;
            Select Date
          &lt;/Label&gt;
          &lt;Popover open={open} onOpenChange={setOpen}&gt;
            &lt;PopoverTrigger
              onPointerDown={() =&gt; setBookingStatus("idle")}
              render={
                &lt;Button
                  variant="outline"
                  id="date"
                  className={cn(
                    "w-full justify-start text-left font-normal h-10 transition-all hover:bg-muted/50 cursor-pointer",
                    !date &amp;&amp; "text-muted-foreground",
                  )}
                &gt;
                  &lt;CalendarIcon className="mr-2 h-4 w-4 opacity-70" /&gt;
                  {date ? format(date, "PPP") : &lt;span&gt;Select a date&lt;/span&gt;}
                  &lt;ChevronDown className="ml-auto h-4 w-4 opacity-50" /&gt;
                &lt;/Button&gt;
              }
            /&gt;
            &lt;PopoverContent
              className="w-auto p-0 border-muted-foreground/10 shadow-2xl"
              align="start"
            &gt;
              &lt;Calendar
                mode="single"
                selected={date}
                onSelect={(d) =&gt; {
                  setDate(d);
                  setOpen(false);
                }}
                className="rounded-md border-none"
              /&gt;
            &lt;/PopoverContent&gt;
          &lt;/Popover&gt;
        &lt;/div&gt;

        &lt;div className="grid grid-cols-2 gap-4"&gt;
          &lt;div className="grid gap-2"&gt;
            &lt;Label
              htmlFor="time-from"
              className="text-sm font-semibold text-muted-foreground flex items-center gap-1.5"
            &gt;
              &lt;Clock className="size-3.5" /&gt; Start Time
            &lt;/Label&gt;
            &lt;Input
              type="time"
              id="time-from"
              defaultValue="09:00"
              className="h-10 bg-background appearance-none transition-all focus:ring-2 focus:ring-primary/20"
            /&gt;
          &lt;/div&gt;
          &lt;div className="grid gap-2"&gt;
            &lt;Label
              htmlFor="time-to"
              className="text-sm font-semibold text-muted-foreground flex items-center gap-1.5"
            &gt;
              &lt;Clock className="size-3.5" /&gt; End Time
            &lt;/Label&gt;
            &lt;Input
              type="time"
              id="time-to"
              defaultValue="10:00"
              className="h-10 bg-background appearance-none transition-all focus:ring-2 focus:ring-primary/20"
            /&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;Button
          className="w-full h-11 font-semibold transition-all group overflow-hidden relative cursor-pointer"
          onClick={handleBooking}
          disabled={!date || bookingStatus !== "idle"}
        &gt;
          {bookingStatus === "idle" &amp;&amp; (
            &lt;span className="flex items-center gap-2"&gt;Confirm Meet&lt;/span&gt;
          )}
          {bookingStatus === "loading" &amp;&amp; (
            &lt;div className="flex items-center gap-2"&gt;
              &lt;div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /&gt;
              Processing...
            &lt;/div&gt;
          )}
          {bookingStatus === "success" &amp;&amp; (
            &lt;span className="flex items-center gap-2 animate-in zoom-in-50 duration-300"&gt;
              &lt;Check className="h-4 w-4" /&gt;
              Meet Scheduled!
            &lt;/span&gt;
          )}
        &lt;/Button&gt;
      &lt;/div&gt;
    &lt;/&gt;
  );
};

export default DateAndTimePickerDemo;
</code></pre>
<h3 id="heading-how-this-component-works">How This Component Works</h3>
<p>This component combines three pieces of state into a simple booking flow:</p>
<ul>
<li><p>The selected date</p>
</li>
<li><p>Start and end times</p>
</li>
<li><p>The booking status (idle, loading, success)</p>
</li>
</ul>
<p>The date is stored using React state:</p>
<pre><code class="language-javascript">const [date, setDate] = useState&lt;Date | undefined&gt;(undefined);
</code></pre>
<p>When a user selects a day from the calendar, the <code>onSelect</code> callback updates the state and closes the popover:</p>
<pre><code class="language-javascript">onSelect={(d) =&gt; {
  setDate(d);
  setOpen(false);
}}
</code></pre>
<p>The calendar itself lives inside a <code>Popover</code>, which keeps the UI compact. Clicking the trigger button opens the calendar panel:</p>
<pre><code class="language-plaintext">&lt;Popover open={open} onOpenChange={setOpen}&gt;
</code></pre>
<p>The displayed date uses <code>date-fns</code> formatting:</p>
<pre><code class="language-plaintext">format(date, "PPP")
</code></pre>
<p>This converts a JavaScript <code>Date</code> object into a readable format such as:</p>
<pre><code class="language-plaintext">July 22, 2026
</code></pre>
<p>The time fields use native HTML inputs:</p>
<pre><code class="language-plaintext">&lt;Input type="time" /&gt;
</code></pre>
<p>Native time inputs provide built-in browser support, mobile pickers, keyboard accessibility, and locale-aware formatting without additional libraries.</p>
<p>The booking button demonstrates how UI state can change during an async action:</p>
<pre><code class="language-plaintext">"idle" → "loading" → "success"
</code></pre>
<p>In a real application, <code>handleBooking()</code> would typically call an API endpoint instead of using <code>setTimeout</code>.</p>
<p>This pattern works well for:</p>
<ul>
<li><p>Meeting schedulers</p>
</li>
<li><p>Appointment systems</p>
</li>
<li><p>Interview booking tools</p>
</li>
<li><p>Event registration forms</p>
</li>
<li><p>SaaS scheduling workflows</p>
</li>
</ul>
<h2 id="heading-how-to-build-a-date-range-picker"><strong>How to Build a Date Range Picker</strong></h2>
<p>For anything involving a stay, a rental, or a multi-day booking, you need a range instead of a single date. This component pairs a two-month calendar view with a formatted range label and a computed night count.</p>
<p><strong>Folder structure:</strong></p>
<pre><code class="language-javascript">components
└── shadcn-space
    └── date-picker
        └── date-picker-02.tsx
</code></pre>
<p>Install this Shadcn Date Range Picker with:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/date-picker-02
</code></pre>
<p><strong>Component code:</strong></p>
<pre><code class="language-javascript">"use client";
import * as React from "react";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { addDays, format } from "date-fns";
import { DateRange } from "react-day-picker";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Label } from "@/components/ui/label";

const DateRangePickerDemo = () =&gt; {
  const [date, setDate] = React.useState&lt;DateRange | undefined&gt;({
    from: new Date(),
    to: addDays(new Date(), 7),
  });

  return (
    &lt;div className="grid gap-3 max-w-sm mx-auto"&gt;
      &lt;Label htmlFor="date-range" className="text-sm font-medium px-1"&gt;
        Select Travel Dates
      &lt;/Label&gt;
      &lt;div className={cn("grid gap-2")}&gt;
        &lt;Popover&gt;
          &lt;PopoverTrigger
            render={
              &lt;Button
                id="date-range"
                variant={"outline"}
                className={cn(
                  "w-full justify-start text-left font-normal h-11 transition-all hover:bg-muted/50 focus:ring-2 focus:ring-primary/20 cursor-pointer",
                  !date &amp;&amp; "text-muted-foreground",
                )}
              &gt;
                &lt;CalendarIcon className="mr-2 h-4 w-4 opacity-70" /&gt;
                {date?.from ? (
                  date.to ? (
                    &lt;&gt;
                      {format(date.from, "LLL dd, y")} -{" "}
                      {format(date.to, "LLL dd, y")}
                    &lt;/&gt;
                  ) : (
                    format(date.from, "LLL dd, y")
                  )
                ) : (
                  &lt;span&gt;Pick a range&lt;/span&gt;
                )}
                &lt;ChevronDown className="ml-auto h-4 w-4 opacity-50" /&gt;
              &lt;/Button&gt;
            }
          /&gt;
          &lt;PopoverContent
            className="w-auto p-0 border-muted/20 shadow-xl"
            align="start"
          &gt;
            &lt;Calendar
              mode="range"
              defaultMonth={date?.from}
              selected={date}
              onSelect={setDate}
              numberOfMonths={2}
              className="p-3"
            /&gt;
          &lt;/PopoverContent&gt;
        &lt;/Popover&gt;
      &lt;/div&gt;
      &lt;p className="text-xs text-muted-foreground px-1"&gt;
        {date?.from &amp;&amp; date?.to
          ? `Stay duration: ${Math.round((date.to.getTime() - date.from.getTime()) / (1000 * 60 * 60 * 24))} nights`
          : "Please select a valid date range."}
      &lt;/p&gt;
    &lt;/div&gt;
  );
};

export default DateRangePickerDemo;
</code></pre>
<h3 id="heading-how-the-date-range-picker-works">How the Date Range Picker Works</h3>
<p>This version switches the Calendar component from single-date mode to range mode:</p>
<pre><code class="language-javascript">mode="range"
</code></pre>
<p>Instead of storing one <code>Date</code>, the component stores a <code>DateRange</code> object:</p>
<pre><code class="language-javascript">{
  from: Date,
  to: Date
}
</code></pre>
<p>This makes it easy to work with booking systems, hotel stays, travel forms, and rental applications.</p>
<p>The component displays two calendar months:</p>
<pre><code class="language-javascript">numberOfMonths={2}
</code></pre>
<p>Showing two months reduces navigation and improves the selection experience for longer stays.</p>
<p>The stay duration is calculated directly from the selected dates:</p>
<pre><code class="language-javascript">(date.to.getTime() - date.from.getTime())
</code></pre>
<p>This avoids additional libraries and keeps the logic simple.</p>
<p>Compared to the first example:</p>
<ul>
<li><p>Uses <code>DateRange</code> instead of a single <code>Date</code></p>
</li>
<li><p>Uses <code>mode="range"</code></p>
</li>
<li><p>Displays two months</p>
</li>
<li><p>Adds derived data like total nights</p>
</li>
</ul>
<h2 id="heading-how-to-build-a-time-picker"><strong>How to Build a Time Picker</strong></h2>
<p>Not every form needs a calendar. This one wraps the native in an InputGroup with a clock icon that triggers the browser's own time picker UI on click.</p>
<p><strong>Folder structure:</strong></p>
<pre><code class="language-javascript">components
└── shadcn-space
    └── date-picker
        └── date-picker-03.tsx
</code></pre>
<p>Install this Shadcn Time Picker with:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/date-picker-03
</code></pre>
<p><strong>Component code:</strong></p>
<pre><code class="language-javascript">"use client";
import { useRef } from "react";
import { Label } from "@/components/ui/label";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import { Clock8Icon } from "lucide-react";

const TimePickerWithIconDemo = () =&gt; {
  const inputRef = useRef&lt;HTMLInputElement&gt;(null);

  const handleShowPicker = () =&gt; {
    if (inputRef.current &amp;&amp; "showPicker" in inputRef.current) {
      try {
        inputRef.current.showPicker();
      } catch (error) {
        console.error("Failed to open native picker:", error);
      }
    }
  };

  return (
    &lt;div className="flex w-full max-w-xs flex-col gap-2"&gt;
      &lt;Label htmlFor="time-picker"&gt;Select Slot&lt;/Label&gt;
      &lt;InputGroup&gt;
        &lt;InputGroupAddon
          align="inline-start"
          className="cursor-pointer hover:text-foreground transition-colors"
          onClick={handleShowPicker}
          title="Open time picker"
        &gt;
          &lt;Clock8Icon className="size-4" /&gt;
        &lt;/InputGroupAddon&gt;
        &lt;InputGroupInput
          ref={inputRef}
          type="time"
          id="time-picker"
          step="1"
          defaultValue="08:30:00"
          className="appearance-none [&amp;::-webkit-calendar-picker-indicator]:hidden [&amp;::-webkit-calendar-picker-indicator]:appearance-none"
        /&gt;
      &lt;/InputGroup&gt;
    &lt;/div&gt;
  );
};
export default TimePickerWithIconDemo;
</code></pre>
<h3 id="heading-how-the-time-picker-works">How the Time Picker Works</h3>
<p>This component uses the browser's native time picker instead of building a custom dropdown.</p>
<p>The input is referenced with <code>useRef</code>:</p>
<pre><code class="language-javascript">const inputRef = useRef&lt;HTMLInputElement&gt;(null);
</code></pre>
<p>This allows the icon button to access the input element directly.</p>
<p>When users click the clock icon, the browser's picker opens programmatically:</p>
<pre><code class="language-javascript">inputRef.current.showPicker();
</code></pre>
<p>The <code>showPicker()</code> method is a modern browser API that opens the same interface users would see if they clicked the input manually.</p>
<p>The component wraps the input inside an <code>InputGroup</code>:</p>
<pre><code class="language-plaintext">&lt;InputGroup&gt;
</code></pre>
<p>This creates a cleaner layout where the icon behaves as part of the field rather than a separate button.</p>
<p>The browser's default picker icon is hidden:</p>
<pre><code class="language-plaintext">[&amp;::-webkit-calendar-picker-indicator]:hidden
</code></pre>
<p>This prevents duplicate icons and gives full control over the UI.</p>
<p>The benefit of this approach is that it keeps:</p>
<ul>
<li><p>Native accessibility</p>
</li>
<li><p>Mobile keyboard support</p>
</li>
<li><p>Locale-aware formatting</p>
</li>
<li><p>Better browser compatibility</p>
</li>
</ul>
<p>Instead of rebuilding time selection from scratch, the component improves the native experience with custom styling.</p>
<h2 id="heading-live-preview-of-the-components"><strong>Live Preview of the Components</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/f815455d-9f2d-4ad1-ac9c-155c37aae094.gif" alt="f815455d-9f2d-4ad1-ac9c-155c37aae094" style="display:block;margin:0 auto" width="1152" height="648" loading="lazy">

<h2 id="heading-key-concepts-recap"><strong>Key Concepts Recap</strong></h2>
<ul>
<li><p><strong>Pick the pattern that matches the data you're collecting</strong>: A single date and time for bookings, a range for stays, a bare time field for slots.</p>
</li>
<li><p><strong>Every component installs the same way</strong>: One CLI command per component: <code>pnpm</code>, <code>npm</code>, <code>yarn</code>, and <code>bun</code>. All are supported. Adding a new picker to a project takes one line, not a manual build.</p>
</li>
<li><p><strong>The date and time picker separates date state from time state</strong>, then combines them at the confirmation step. This keeps the calendar and the time inputs from getting out of sync.</p>
</li>
<li><p><strong>The range picker leans on the Calendar's built-in</strong> <code>mode="range"</code>: The two-month view and the night count come from the selection state, not custom range math.</p>
</li>
<li><p><strong>The time picker wraps the native</strong> <code>&lt;input type="time"&gt;</code> <strong>instead of replacing it</strong>: This keeps native accessibility and mobile keyboard behavior intact.</p>
</li>
</ul>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>You now have working picker patterns for React: a date and time picker for bookings, a date range picker for stays, and a plain time picker for slots.</p>
<p>Each one installs with a single CLI command, so you can pick whichever one matches the form you're building and have it running in minutes.</p>
<h2 id="heading-resources"><strong>Resources</strong></h2>
<ul>
<li><p><a href="https://ui.shadcn.com/docs"><strong>shadcn/ui documentation</strong></a>: For the official Popover, Calendar, and Button component APIs</p>
</li>
<li><p><a href="https://date-fns.org/docs/Getting-Started"><strong>date-fns documentation</strong></a>: For the date helper functions and locale files used in this tutorial</p>
</li>
<li><p><a href="https://daypicker.dev/"><strong>react-day-picker documentation</strong></a>: For the range and multi-date selection modes that power the shadcn/ui Calendar</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions"><strong>MDN: ARIA live regions</strong></a>: For more on how <code>role="alert"</code> works</p>
</li>
<li><p><a href="https://shadcnspace.com/components/date-picker"><strong>Shadcn Date Time Picker components</strong></a>: If you want more prebuilt date and time picker variants to compare against.</p>
</li>
<li><p><a href="https://shadcnspace.com/components"><strong>Full Shadcn components library</strong></a>: For other shadcn/ui-compatible components</p>
</li>
<li><p><a href="https://wrappixel.com/blog/shadcn-date-picker"><strong>Guide to free Shadcn date and time pickers</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use Apple’s Foundation Models in a Web App with a macOS Companion ]]>
                </title>
                <description>
                    <![CDATA[ Not every AI feature needs a cloud model, with its per-token bills, network round-trips, and private data leaving your machine. If you're on a modern Mac, a capable language model is already on your d ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-apple-s-foundation-models-in-a-web-app-with-a-macos-companion/</link>
                <guid isPermaLink="false">6a5e92afe12aa31dae6e8a79</guid>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ macOS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Swift ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Balogun Wahab ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 21:27:11 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/7f0e2343-7394-46b5-a4c8-3ef0fecfa57a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Not every AI feature needs a cloud model, with its per-token bills, network round-trips, and private data leaving your machine. If you're on a modern Mac, a capable language model is already on your disk.</p>
<p><strong>Foundation Models</strong> is Apple's Swift framework for working with large language models. It's the on-device model behind Apple Intelligence, Apple's Private Cloud Compute, or another provider's server model.</p>
<p>This tutorial targets the on-device model: you send it a prompt and it runs entirely on the Mac's own hardware locally, free-per-call, and offline-friendly.</p>
<p>Paired with Apple Vision for reading images on device, that's enough to build real AI features like summaries, classification, and structured extraction without the data ever leaving your machine.</p>
<h2 id="heading-table-of-contents">Table Of Contents</h2>
<ul>
<li><p><a href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-a-macos-companion-app">Why a macOS Companion App?</a></p>
</li>
<li><p><a href="#heading-foundation-models-cant-read-images-directly">Foundation Models Can't Read Images Directly</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-build-the-react-app">Build the React App</a></p>
<ul>
<li><p><a href="#heading-check-companion-health">Check Companion Health</a></p>
</li>
<li><p><a href="#heading-convert-the-image-to-base64">Convert the Image to Base64</a></p>
</li>
<li><p><a href="#heading-analyze-immediately-after-upload">Analyze Immediately After Upload</a></p>
</li>
<li><p><a href="#heading-send-the-image-to-the-companion">Send the Image to the Companion</a></p>
</li>
<li><p><a href="#heading-render-the-json-output">Render the JSON Output</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-build-the-macos-companion-app">Build the macOS Companion App</a></p>
</li>
<li><p><a href="#heading-check-foundation-models-availability">Check Foundation Models Availability</a></p>
</li>
<li><p><a href="#heading-extract-text-with-apple-vision">Extract Text with Apple Vision</a></p>
</li>
<li><p><a href="#heading-ask-foundation-models-to-explain-the-vision-output">Ask Foundation Models to Explain the Vision Output</a></p>
</li>
<li><p><a href="#heading-return-json-to-the-browser">Return JSON to the Browser</a></p>
</li>
<li><p><a href="#heading-run-the-app">Run the App</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>You'll build <strong>Vision Bridge</strong>, a web app that sends an image to a local macOS companion. The companion reads the image with Apple Vision, reasons about it with Foundation Models, and returns structured JSON to the browser: private, on-device AI behind a plain web interface.</p>
<p>You can find the complete source code in this GitHub repository: <a href="http://github.com/03balogun/vision-bridge">github.com/03balogun/vision-bridge</a>.</p>
<p>The goal isn't to build a giant product but rather to understand the architecture behind how this works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5db93b3da2342e8354088115/6d18db01-e921-4291-bb2e-26be2c02b304.png" alt="Screenshot of the Vision Bridge app, with image upload on the left and JSON output on the right" style="display:block;margin:0 auto" width="3024" height="1714" loading="lazy">

<p>Vision Bridge has two parts:</p>
<ul>
<li><p>A React app with a split-screen interface.</p>
</li>
<li><p>A macOS companion app that exposes a local API.</p>
</li>
</ul>
<p>The React app has:</p>
<ul>
<li><p>An image upload area</p>
</li>
<li><p>An image preview</p>
</li>
<li><p>Automatic analysis after upload</p>
</li>
<li><p>A JSON output viewer</p>
</li>
<li><p>A companion health status indicator</p>
</li>
</ul>
<p>The macOS companion app has:</p>
<ul>
<li><p><code>GET /v1/health</code></p>
</li>
<li><p><code>POST /v1/analyze-image</code></p>
</li>
<li><p>Apple Vision OCR</p>
</li>
<li><p>Foundation Models availability checks</p>
</li>
<li><p>Foundation Models reasoning over Vision output</p>
</li>
</ul>
<p>The final response looks like this:</p>
<pre><code class="language-json">{
  "support": {
    "visionAvailable": true,
    "foundationModelAvailable": true,
    "foundationModelStatus": "available"
  },
  "image": {
    "filename": "screenshot.png",
    "contentType": "image/png",
    "byteCount": 1048576,
    "width": 1440,
    "height": 900
  },
  "vision": {
    "detectedText": [
      {
        "text": "Build failed",
        "confidence": 0.96,
        "boundingBox": {
          "x": 0.12,
          "y": 0.31,
          "width": 0.45,
          "height": 0.08
        }
      }
    ]
  },
  "model": {
    "summary": "The image appears to show a software build failure.",
    "description": "A developer tool window is showing an error state with diagnostic text.",
    "suggestedTags": ["screenshot", "developer-tool", "error"],
    "possibleUses": [
      "Generate alt text",
      "Summarize screenshots",
      "Extract document data"
    ]
  }
}
</code></pre>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you need:</p>
<ul>
<li><p>macOS 26 or newer</p>
</li>
<li><p>Xcode with the macOS 26 SDK</p>
</li>
<li><p>Node.js 20 or newer</p>
</li>
<li><p>Basic React knowledge</p>
</li>
<li><p>Basic Swift knowledge</p>
</li>
<li><p>A Mac that supports Apple Intelligence</p>
</li>
</ul>
<p>Foundation Models availability depends on the Mac, the OS version, and Apple Intelligence settings. The companion checks this at runtime, which we'll cover below.</p>
<h2 id="heading-why-a-macos-companion-app">Why a macOS Companion App?</h2>
<p>You can't write this in a regular React app:</p>
<pre><code class="language-ts">import FoundationModels from "apple-frameworks";
</code></pre>
<p>That API doesn't exist in the browser. A native macOS app, however, can use any Apple framework, so the companion acts as a local bridge. The same pattern works for any native capability the web platform doesn't expose.</p>
<h2 id="heading-foundation-models-cant-read-images-directly">Foundation Models Can't Read Images Directly</h2>
<p>The public Foundation Models framework is a language model interface. It doesn't currently expose direct image input the way a multimodal cloud model might, so this tutorial never sends the image to the model. Instead, the companion feeds the Vision OCR observations and image metadata into the prompt. The model reasons over structured text, never the original pixels.</p>
<p>That split plays to each framework's strength: Vision is excellent at pulling machine-readable information out of images, and Foundation Models turns that information into summaries, labels, explanations, and structured output.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5db93b3da2342e8354088115/a5c11ad4-dcac-4690-bc6d-08b27fd6fed8.png" alt="Vision Bridge architecture: the browser sends the image over localhost to the Swift companion, which runs Apple Vision OCR, feeds the observations to Foundation Models, and returns structured JSON" style="display:block;margin:0 auto" width="2492" height="1572" loading="lazy">

<p>The above diagram shows the round trip that the rest of this tutorial builds. The browser sends the uploaded image as base64 JSON over localhost to the Swift companion. Inside the companion, Apple Vision runs OCR on the image and produces text observations: the recognized strings, their confidence scores, and their bounding boxes.</p>
<p>Those observations, not the image itself, are formatted into a prompt for Foundation Models, which generates a summary, description, and tags. The companion then bundles the Vision output and the model output into one JSON response and returns it to the browser.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>Create a project with this structure:</p>
<pre><code class="language-text">vision-bridge/
  apps/
    web/
      src/
        main.tsx
        styles.css
      package.json
      vite.config.ts
    macos-companion/
      Package.swift
      Sources/
        VisionBridgeCompanion/
          main.swift
  package.json
  README.md
</code></pre>
<p>The root <code>package.json</code> gives us a few convenient commands:</p>
<pre><code class="language-json">{
  "scripts": {
    "dev": "npm --workspace apps/web run dev",
    "build": "npm --workspace apps/web run build",
    "companion": "swift run --package-path apps/macos-companion VisionBridgeCompanion"
  },
  "workspaces": ["apps/web"]
}
</code></pre>
<h2 id="heading-build-the-react-app">Build the React App</h2>
<p>The web app is intentionally simple. It has one job: let the user pick an image and show the JSON returned by the companion.</p>
<p>The web app uses Vite, React, Lucide icons, and a JSON viewer:</p>
<pre><code class="language-json">{
  "dependencies": {
    "@vitejs/plugin-react": "^6.0.3",
    "lucide-react": "^0.468.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-json-view-lite": "^2.5.0",
    "vite": "^8.1.3"
  }
}
</code></pre>
<p>After defining the dependencies, install them:</p>
<pre><code class="language-plaintext">npm install
</code></pre>
<p>The API base URL points to the local companion:</p>
<pre><code class="language-ts">const API_BASE_URL = "http://127.0.0.1:43119";
</code></pre>
<h3 id="heading-check-companion-health">Check Companion Health</h3>
<p>The web app pings the companion so the UI can show whether the native bridge is online:</p>
<pre><code class="language-ts">async function checkHealth() {
  setHealthError(null);

  try {
    const response = await fetch(`${API_BASE_URL}/v1/health`);
    if (!response.ok) {
      throw new Error(`Health check failed with ${response.status}`);
    }

    const payload = await response.json();
    setHealth(payload);
  } catch (error) {
    setHealth(null);
    setHealthError(error instanceof Error ? error.message : "Companion unavailable");
  }
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5db93b3da2342e8354088115/dc3c37eb-1d9c-4b44-82db-f38adada4f19.png" alt="Screenshot of the companion online status pill" style="display:block;margin:0 auto" width="732" height="212" loading="lazy">

<h3 id="heading-convert-the-image-to-base64">Convert the Image to Base64</h3>
<p>When the user selects a file, the app converts it to base64 so it can be sent as JSON:</p>
<pre><code class="language-ts">function readFileAsBase64(file: File) {
  return new Promise&lt;string&gt;((resolve, reject) =&gt; {
    const reader = new FileReader();
    reader.onload = () =&gt; {
      const result = String(reader.result);
      resolve(result.includes(",") ? result.split(",")[1] : result);
    };
    reader.onerror = () =&gt; reject(reader.error);
    reader.readAsDataURL(file);
  });
}
</code></pre>
<p>This isn't the only way to upload files. You could also use <code>multipart/form-data</code>, but JSON keeps the demo easy to inspect.</p>
<h3 id="heading-analyze-immediately-after-upload">Analyze Immediately After Upload</h3>
<p>The app starts analysis as soon as an image is uploaded:</p>
<pre><code class="language-ts">async function handleFile(file: File) {
  if (!file.type.startsWith("image/")) {
    setError("Choose a PNG, JPEG, HEIC, or another browser-readable image.");
    return;
  }

  const base64 = await readFileAsBase64(file);
  const nextImage = {
    file,
    previewUrl: URL.createObjectURL(file),
    base64,
  };

  setSelectedImage(nextImage);
  setAnalysis(null);
  setError(null);
  setCopied(false);

  analyzeImage(nextImage);
}
</code></pre>
<p><code>handleFile</code> does the preparation work for every new image. It rejects anything that isn't a browser-readable image, converts the file to base64, and builds a single object holding everything the rest of the flow needs: the original <code>File</code> (for its name and MIME type), an object URL for the preview, and the base64 payload for the API call.</p>
<p>It then clears out the previous run the old analysis, any error message, and the "copied" indicator so the UI never shows results from the last image next to a new one. Finally, it kicks off <code>analyzeImage(nextImage)</code> immediately.</p>
<p>Note that it passes the fresh object directly instead of relying on the <code>selectedImage</code> state: React state updates don't apply until the next render, so reading the state here would still give you the <em>previous</em> image.</p>
<p>The <code>Analyze</code> button still exists in the UI, but it works as a manual rerun button.</p>
<h3 id="heading-send-the-image-to-the-companion">Send the Image to the Companion</h3>
<p>Here's the core request:</p>
<pre><code class="language-ts">const analysisRequestId = useRef(0);

async function analyzeImage(image = selectedImage) {
  if (!image) {
    setError("Choose an image first.");
    return;
  }

  const requestId = analysisRequestId.current + 1;
  analysisRequestId.current = requestId;

  setRequestState("loading");
  setError(null);
  setCopied(false);

  try {
    const response = await fetch(`${API_BASE_URL}/v1/analyze-image`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        filename: image.file.name,
        mimeType: image.file.type || "application/octet-stream",
        base64: image.base64,
      }),
    });

    const payload = await response.json();

    if (requestId !== analysisRequestId.current) {
      return;
    }

    if (!response.ok) {
      throw new Error(payload.error?.message ?? `Analysis failed with ${response.status}`);
    }

    setAnalysis(payload);
    setRequestState("success");
  } catch (error) {
    if (requestId !== analysisRequestId.current) {
      return;
    }

    setRequestState("error");
    setError(error instanceof Error ? error.message : "Could not analyze image");
  }
}
</code></pre>
<p>This function is the entire client side of the bridge. It flips <code>requestState</code> to <code>loading</code> (which drives the spinner and disables the button), then sends a <code>POST</code> to <code>/v1/analyze-image</code> with a JSON body containing three fields: the filename, the MIME type, and the base64 image data. That body maps one-to-one onto the <code>AnalyzeImageRequest</code> struct the Swift companion decodes later.</p>
<p>Notice that the response is parsed as JSON <em>before</em> checking <code>response.ok</code>. That's deliberate: when the companion rejects a request (bad base64, oversized image), it still returns a JSON body with an <code>error.message</code> field, so the UI can show the companion's own explanation instead of a generic status code. On success, the payload goes straight into state, and the JSON viewer re-renders with the result.</p>
<p>The <code>requestId</code> bookkeeping guards against stale responses. If a user uploads a second image while the first is still analyzing, whichever request finishes <em>last</em> would win, and OCR plus model generation takes long enough that responses can genuinely arrive out of order. So every call increments a counter stored in a ref and remembers its own ID.</p>
<p>After the <code>await</code>, it checks whether it's still the newest request; if a newer upload started in the meantime, the older response is silently discarded instead of overwriting the latest image's result. The same check runs in the <code>catch</code> block, so an old failure can't clobber a newer success either. If you also want to cancel the in-flight HTTP request rather than just ignore its result, an <code>AbortController</code> is the natural next step.</p>
<h3 id="heading-render-the-json-output">Render the JSON Output</h3>
<p>The output pane uses <code>react-json-view-lite</code>:</p>
<pre><code class="language-tsx">&lt;JsonView
  data={jsonData}
  shouldExpandNode={allExpanded}
  style={jsonViewTheme}
/&gt;
</code></pre>
<h2 id="heading-build-the-macos-companion-app">Build the macOS Companion App</h2>
<p>The companion is a Swift command-line app. It exposes a small local HTTP API.</p>
<p>If you come from the web side, the mapping is simple: Swift Package Manager is Swift's npm, <code>Package.swift</code> is its <code>package.json</code>, and <code>swift run</code> is its <code>npm start</code>. It ships with Xcode, so there's nothing extra to install.</p>
<p>The <code>Package.swift</code> file looks like this:</p>
<pre><code class="language-swift">// swift-tools-version: 6.0

import PackageDescription

let package = Package(
    name: "VisionBridgeCompanion",
    platforms: [
        .macOS("26.0")
    ],
    products: [
        .executable(
            name: "VisionBridgeCompanion",
            targets: ["VisionBridgeCompanion"]
        )
    ],
    targets: [
        .executableTarget(
            name: "VisionBridgeCompanion"
        )
    ]
)
</code></pre>
<p>The companion imports the Apple frameworks it needs:</p>
<pre><code class="language-swift">import Foundation
import FoundationModels
import ImageIO
import Network
import Vision
</code></pre>
<p>It listens on <code>127.0.0.1:43119</code>:</p>
<pre><code class="language-swift">private let defaultPort: UInt16 = 43119
</code></pre>
<p>The app exposes two routes:</p>
<pre><code class="language-swift">switch (request.method, request.path) {
case ("GET", "/v1/health"):
    let health = HealthResponse(support: ModelSupport.current)
    return try json(health)

case ("POST", "/v1/analyze-image"):
    let payload = try JSONDecoder().decode(AnalyzeImageRequest.self, from: request.body)
    let response = try await service.analyze(payload)
    return try json(response)

default:
    return try json(
        ErrorResponse(error: APIErrorPayload(message: "Route not found")),
        status: .notFound
    )
}
</code></pre>
<p>This <code>switch</code> is the companion's entire routing layer — no web framework, just pattern matching on the method and path.</p>
<p>The two routes split the work cleanly:</p>
<ul>
<li><p><code>GET /v1/health</code> is the cheap, read-only route. It runs no analysis, it just reports whether Vision and Foundation Models are usable on this Mac via <code>ModelSupport.current</code> (covered in the next section). The React app calls it on load to render the online/offline status pill, so the user knows the bridge is up before they upload anything.</p>
</li>
<li><p><code>POST /v1/analyze-image</code> is where the real work happens. It decodes the request body into an <code>AnalyzeImageRequest</code> (with the same <code>filename</code>, <code>mimeType</code>, and <code>base64</code> fields the browser sent) and hands it to the analysis service. This validates the image, runs Vision OCR, prompts Foundation Models, and returns the combined result. The <code>try await</code> matters here: analysis is asynchronous, and the route simply waits for it before serializing the response.</p>
</li>
</ul>
<p>Anything else falls through to a JSON 404, so even unknown routes respond in the same format the browser already knows how to parse.</p>
<p>Errors work the same way: thrown errors are caught in one place and converted into JSON error responses with an appropriate status code, which is exactly what the web app's <code>payload.error?.message</code> check reads.</p>
<p>One practical detail: because the browser calls the companion from a different origin (the Vite dev server), every response also carries CORS headers, and the router answers preflight <code>OPTIONS</code> requests with an empty <code>204</code>. Without that, the browser would block the <code>fetch</code> before it ever reached these routes.</p>
<h2 id="heading-check-foundation-models-availability">Check Foundation Models Availability</h2>
<p>The companion shouldn't assume that the model is available. Check it first:</p>
<pre><code class="language-swift">private struct ModelSupport: Encodable {
    let visionAvailable: Bool
    let foundationModelAvailable: Bool
    let foundationModelStatus: String

    static var current: ModelSupport {
        let model = SystemLanguageModel.default

        switch model.availability {
        case .available:
            return ModelSupport(
                visionAvailable: true,
                foundationModelAvailable: true,
                foundationModelStatus: "available"
            )

        case .unavailable(let reason):
            return ModelSupport(
                visionAvailable: true,
                foundationModelAvailable: false,
                foundationModelStatus: "unavailable.\(reason.description)"
            )

        @unknown default:
            return ModelSupport(
                visionAvailable: true,
                foundationModelAvailable: false,
                foundationModelStatus: "unavailable.unknown"
            )
        }
    }
}
</code></pre>
<p>A user might have an unsupported Mac, Apple Intelligence might be disabled, or the model might not be ready yet. The response tells the browser which case it's dealing with.</p>
<h2 id="heading-extract-text-with-apple-vision">Extract Text with Apple Vision</h2>
<p>The companion decodes the base64 image, checks its metadata, then runs Vision OCR.</p>
<p>Here's the text recognition flow:</p>
<pre><code class="language-swift">private func recognizeText(in imageData: Data) async throws -&gt; [DetectedText] {
    var request = RecognizeTextRequest()
    request.recognitionLevel = .accurate
    request.automaticallyDetectsLanguage = true
    request.usesLanguageCorrection = true

    let observations = try await request.perform(on: imageData)

    var detectedText: [DetectedText] = []

    for observation in observations {
        guard let candidate = observation.topCandidates(1).first else {
            continue
        }

        let bounds = NormalizedBox.from(points: [
            observation.topLeft,
            observation.topRight,
            observation.bottomRight,
            observation.bottomLeft
        ])

        detectedText.append(DetectedText(
            text: candidate.string,
            confidence: Double(candidate.confidence),
            boundingBox: bounds
        ))
    }

    return detectedText
}
</code></pre>
<p>Vision gives us structured observations:</p>
<ul>
<li><p>recognized text</p>
</li>
<li><p>confidence scores</p>
</li>
<li><p>normalized bounding boxes</p>
</li>
</ul>
<p>Those observations become the model’s context.</p>
<h2 id="heading-ask-foundation-models-to-explain-the-vision-output">Ask Foundation Models to Explain the Vision Output</h2>
<p>Now the companion creates a prompt from the image metadata and OCR results.</p>
<p>Notice the instruction:</p>
<pre><code class="language-text">You cannot see the original image. Use only the metadata and OCR observations below.
</code></pre>
<p>That keeps the model honest. It shouldn't pretend to see pixels it never received.</p>
<p>Here's the prompt shape:</p>
<pre><code class="language-swift">let textPreview = detectedText
    .prefix(30)
    .map { "- \($0.text) (confidence: \(String(format: "%.2f", $0.confidence)))" }
    .joined(separator: "\n")

let prompt = """
You are summarizing Apple Vision OCR output for a developer tool named Vision Bridge.
You cannot see the original image. Use only the metadata and OCR observations below.

Image:
- filename: \(image.filename)
- content type: \(image.contentType)
- size: \(image.width ?? 0)x\(image.height ?? 0)

OCR observations:
\(textPreview.isEmpty ? "- No text detected." : textPreview)

Return a compact JSON object with these exact keys:
summary: one sentence
description: one short paragraph
suggestedTags: 3 to 6 short tags
possibleUses: 3 to 5 practical use cases for this kind of image analysis
"""
</code></pre>
<p>Then call the model:</p>
<pre><code class="language-swift">let session = LanguageModelSession(
    model: .default,
    instructions: "Return valid JSON only. Do not include Markdown fences."
)

let response = try await session.respond(to: prompt)
let raw = response.content.trimmingCharacters(in: .whitespacesAndNewlines)
</code></pre>
<p>Even when you ask for JSON, always validate the output. Models can still return Markdown fences or malformed text. The sample app strips simple Markdown code fences and falls back to a raw response if parsing fails.</p>
<h2 id="heading-return-json-to-the-browser">Return JSON to the Browser</h2>
<p>The companion combines the support state, image metadata, Vision results, and model output:</p>
<pre><code class="language-swift">return AnalyzeImageResponse(
    support: support,
    image: metadata,
    vision: VisionPayload(detectedText: detectedText),
    model: modelInsight
)
</code></pre>
<p>The browser doesn't need to know how Vision or Foundation Models work. It just receives JSON. The native app owns the native capabilities, while the web app owns the interface.</p>
<p>It's worth pausing on what each of the four blocks actually gives you, because they're not all the same kind of data:</p>
<ul>
<li><p><code>support</code> tells you what was possible on this Mac. If <code>foundationModelAvailable</code> is <code>false</code>, the <code>model</code> block still exists but contains a fallback message rather than real analysis, and the <code>foundationModelStatus</code> string (for example, <code>unavailable.appleIntelligenceNotEnabled</code>) tells the UI <em>why</em>, so it can explain rather than silently degrade.</p>
</li>
<li><p><code>image</code> echoes back the file's metadata plus the measured pixel dimensions. It's useful as a sanity check, and you need the width and height to do anything spatial with the Vision results.</p>
</li>
<li><p><code>vision</code> is the ground truth. Each entry in <code>detectedText</code> is a string Vision actually found, with a confidence score between 0 and 1 and a normalized bounding box: coordinates expressed as fractions of the image size, so <code>x: 0.12, width: 0.45</code> means "starts 12% from the left and spans 45% of the width." Because the boxes are normalized, you can draw highlight overlays on the preview at any display size by multiplying by the rendered dimensions. Low-confidence entries are worth filtering or flagging before you trust them.</p>
</li>
<li><p><code>model</code> is interpretation, not observation. The <code>summary</code>, <code>description</code>, <code>suggestedTags</code>, and <code>possibleUses</code> fields are generated by the language model from the OCR text. This is useful as alt text, captions, or tag suggestions, but they inherit whatever the OCR missed and should be treated as a draft, not a fact. When the model's output can't be parsed as JSON, <code>rawResponse</code> carries the unparsed text so nothing is lost.</p>
</li>
</ul>
<p>For a screenshot of a failed build, the model block might come back like this:</p>
<pre><code class="language-json">{
  "model": {
    "summary": "The image appears to show a software build failure.",
    "description": "A developer tool window is showing an error state with diagnostic text.",
    "suggestedTags": ["screenshot", "developer-tool", "error"],
    "possibleUses": [
      "Generate alt text",
      "Summarize screenshots",
      "Extract document data"
    ]
  }
}
</code></pre>
<p>That combination (exact text with positions from Vision, plus a human-readable interpretation from the model) is enough to build real features on top of a searchable screenshot library indexed by <code>detectedText</code> and <code>suggestedTags</code>, automatic alt text for uploaded images, or click-to-highlight overlays powered by the bounding boxes.</p>
<p>And because the prompt lives in the companion, changing what comes back (say, extracting line items from receipts instead of tagging screenshots) is a prompt edit, not an architecture change.</p>
<h2 id="heading-run-the-app">Run the App</h2>
<p>Start the companion:</p>
<pre><code class="language-sh">npm run companion
</code></pre>
<p>In another terminal, start the web app:</p>
<pre><code class="language-sh">npm run dev
</code></pre>
<p>Open the Vite URL:</p>
<pre><code class="language-text">http://127.0.0.1:5173
</code></pre>
<p>If that port is busy, Vite will choose another one.</p>
<p>The companion should be available at:</p>
<pre><code class="language-text">http://127.0.0.1:43119
</code></pre>
<p>You can test it directly:</p>
<pre><code class="language-sh">curl http://127.0.0.1:43119/v1/health
</code></pre>
<p>Expected response:</p>
<pre><code class="language-json">{
  "app": "Vision Bridge Companion",
  "ok": true,
  "support": {
    "foundationModelAvailable": true,
    "foundationModelStatus": "available",
    "visionAvailable": true
  },
  "version": "0.1.0"
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5db93b3da2342e8354088115/76a47c9a-934c-4133-ba7c-e2a9c6b6dad4.png" alt="Screenshot of terminal running companion" style="display:block;margin:0 auto" width="1448" height="556" loading="lazy">

<h2 id="heading-conclusion">Conclusion</h2>
<p>You now have a React interface that uploads an image, a Swift companion that analyzes it with Apple-native frameworks, and structured JSON flowing between them.</p>
<p>Vision Bridge is intentionally small, but the bridge itself is reusable. Once you have a trusted native companion, a web app can do more than send prompts to a remote model: it can ask the Mac to work with local context, use any Apple framework, and return structured data the browser can render, store, or sync.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://developer.apple.com/documentation/foundationmodels">Apple Foundation Models documentation</a></p>
</li>
<li><p><a href="https://developer.apple.com/documentation/vision">Apple Vision documentation</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Shadcn Sheet Component in React: Cart and Filter Panel Examples ]]>
                </title>
                <description>
                    <![CDATA[ A Sheet is a panel that slides in from the edge of the screen instead of popping up in the center like a modal. You've likely used one when you've opened a shopping cart on an e-commerce site or tappe ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-shadcn-sheet-component-in-react-cart-and-filter-panel-examples/</link>
                <guid isPermaLink="false">6a54f29d91d656f74f1f4a21</guid>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ components ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Mon, 13 Jul 2026 14:13:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/958cd73a-253a-4a48-b83c-5a5d52420b88.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A Sheet is a panel that slides in from the edge of the screen instead of popping up in the center like a modal. You've likely used one when you've opened a shopping cart on an e-commerce site or tapped a filter icon and watched options slide in from the side.</p>
<p>Building a Sheet that scrolls correctly with long content, keeps a header and footer fixed in place, and manages form or counter state without bugs takes more than wrapping a <code>&lt;div&gt;</code> in a slide animation.</p>
<p>In this tutorial, you'll build two production-ready Sheet components using shadcn/ui and Base UI primitives via Shadcn Space:</p>
<ol>
<li><p>A <strong>Shopping Cart Sheet</strong> with quantity controls, item removal, and a live subtotal.</p>
</li>
<li><p>A <strong>Filter Panel Sheet</strong> with category checkboxes, a price range slider, star ratings, and an active filter count.</p>
</li>
</ol>
<p>By the end, you'll have both components running in your project, and you'll understand the state and layout decisions behind them well enough to build your own Sheet variants from scratch.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-cli-registry">How to Set Up the CLI Registry</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-shopping-cart-sheet-sheet-03">How to Build the Shopping Cart Sheet (sheet-03)</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-filter-panel-sheet-sheet-04">How to Build the Filter Panel Sheet (sheet-04)</a></p>
</li>
<li><p><a href="#heading-quick-reference-table">Quick Reference Table</a></p>
</li>
<li><p><a href="#heading-key-concepts-recap">Key Concepts Recap</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>Node.js 18 or higher installed</p>
</li>
<li><p>shadcn/ui initialized in your project (<code>npx shadcn@latest init</code>)</p>
</li>
<li><p>Basic knowledge of React and TypeScript</p>
</li>
</ul>
<p>If you haven't initialized shadcn/ui yet, run <code>npx shadcn@latest init</code> in your project root and follow the prompts before continuing.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>This tutorial uses components installed through the <a href="https://shadcnspace.com/"><strong>Shadcn Space</strong></a> registry, an open-source collection of production-ready components and UI blocks for shadcn/ui.</p>
<p>You can browse the full set in the <a href="https://shadcnspace.com/components/sheet"><strong>Shadcn Sheet component library</strong></a>. Each component supports both Radix UI and Base UI primitives and includes a <strong>Copy Prompt option</strong>, so you can paste the component spec into tools like v0, Lovable, or Bolt if you're prototyping. This tutorial uses the Base UI versions.</p>
<p><strong>Shopping Cart Sheet (sheet-03)</strong></p>
<ul>
<li><p>Slide-in panel from the right with a cart icon trigger and item-count badge</p>
</li>
<li><p>Quantity increment, decrement, and removal per item</p>
</li>
<li><p>Live subtotal and total calculated from cart state</p>
</li>
<li><p>Fixed header and footer with a scrollable item list in between</p>
</li>
</ul>
<p><strong>Filter Panel Sheet (sheet-04)</strong></p>
<ul>
<li><p>Slide-in panel from the left</p>
</li>
<li><p>Multi-select category checkboxes</p>
</li>
<li><p>Price range slider with live min/max display</p>
</li>
<li><p>Single-select star rating filter</p>
</li>
<li><p>Active filter count badge and a "Clear All" action that doesn't close the panel</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-the-cli-registry">How to Set Up the CLI Registry</h2>
<p>Before running any install commands, register the Shadcn Space registry in your <code>components.json</code> file.</p>
<p>Open <code>components.json</code> in your project root and add the <code>registries</code> field:</p>
<pre><code class="language-javascript">{
  "registries": {
    "@shadcn-space": {
      "url": "https://shadcnspace.com/r/{name}.json"
    }
  }
}
</code></pre>
<p>This tells the shadcn CLI where to resolve components prefixed with <code>@shadcn-space/</code>. Without this step, the install commands below will fail.</p>
<p>For a full walkthrough of registry setup, see the <a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli"><strong>getting started guide</strong></a>. Shadcn Space also ships an <a href="https://shadcnspace.com/mcp"><strong>MCP server</strong></a>, so you can browse and install components straight from your editor if your workflow uses MCP tooling. If you prefer following along visually with a video, here you go:</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/mMlxAmJlbMI" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-how-to-build-the-shopping-cart-sheet-sheet-03">How to Build the Shopping Cart Sheet (sheet-03)</h2>
<h3 id="heading-what-the-cart-sheet-does">What the Cart Sheet Does</h3>
<p>The cart icon sits in your navbar. Clicking it slides a panel in from the right showing each item's image, name, variant, and price, with controls to change quantity or remove the item entirely. The subtotal updates as you go, and the footer stays pinned at the bottom regardless of how many items are in the list.</p>
<h3 id="heading-how-to-install-the-cart-sheet">How to Install the Cart Sheet</h3>
<p>Run one of the following based on your package manager:</p>
<p><strong>npm</strong>:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/sheet-03
</code></pre>
<p><strong>pnpm</strong>:</p>
<pre><code class="language-javascript">pnpm dlx shadcn@latest add @shadcn-space/sheet-03
</code></pre>
<p><strong>Yarn</strong>:</p>
<pre><code class="language-javascript">yarn dlx shadcn@latest add @shadcn-space/sheet-03
</code></pre>
<p><strong>Bun</strong>:</p>
<pre><code class="language-javascript">bunx --bun shadcn@latest add @shadcn-space/sheet-03
</code></pre>
<p>The CLI copies the component into your project at:</p>
<pre><code class="language-javascript">components/
  shadcn-space/
    sheet/
      sheet-03.tsx
</code></pre>
<h3 id="heading-the-component-code">The Component Code</h3>
<pre><code class="language-javascript">"use client";
import { useState } from "react";
import { ShoppingCartIcon, PlusIcon, MinusIcon, Trash2Icon } from "lucide-react";
import {
  Sheet,
  SheetTrigger,
  SheetContent,
  SheetHeader,
  SheetTitle,
  SheetDescription,
  SheetFooter,
  SheetClose,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";

const initialItems = [
  {
    id: 1,
    name: "Apple Watch S9",
    variant: "Midnight / 41mm",
    price: 684.0,
    qty: 1,
    image: "https://images.shadcnspace.com/assets/ecommerce/product-category/product-category-03-1.webp",
  },
  {
    id: 2,
    name: "Beige Jacket",
    variant: "Size M / Beige",
    price: 479.0,
    qty: 1,
    image: "https://images.shadcnspace.com/assets/ecommerce/product-category/product-category-02-2.webp",
  },
  {
    id: 3,
    name: "Glow Serum",
    variant: "30ml / Vitamin C",
    price: 46.0,
    qty: 2,
    image: "https://images.shadcnspace.com/assets/ecommerce/product-category/product-category-03-3.webp",
  },
];

const ShoppingCartDemo = () =&gt; {
  const [items, setItems] = useState(initialItems);

  const updateQty = (id, delta) =&gt; {
    setItems((prev) =&gt;
      prev
        .map((item) =&gt; (item.id === id ? { ...item, qty: item.qty + delta } : item))
        .filter((item) =&gt; item.qty &gt; 0)
    );
  };

  const subtotal = items.reduce((sum, item) =&gt; sum + item.price * item.qty, 0);
  const totalCount = items.reduce((sum, item) =&gt; sum + item.qty, 0);

  return (
    &lt;Sheet&gt;
      &lt;SheetTrigger render={&lt;Button variant="outline" size="icon" className="relative cursor-pointer" /&gt;}&gt;
        &lt;ShoppingCartIcon size={18} /&gt;
        {totalCount &gt; 0 &amp;&amp; (
          &lt;Badge className="absolute -top-2 -right-2 size-5 justify-center rounded-full p-0 text-[10px]"&gt;
            {totalCount}
          &lt;/Badge&gt;
        )}
      &lt;/SheetTrigger&gt;

      &lt;SheetContent side="right" className="flex flex-col p-0 gap-0"&gt;
        &lt;SheetHeader className="px-4 pt-5 pb-4 border-b"&gt;
          &lt;SheetTitle&gt;Your Cart&lt;/SheetTitle&gt;
          &lt;SheetDescription&gt;
            {totalCount &gt; 0
              ? `${totalCount} item${totalCount &gt; 1 ? "s" : ""} in your cart`
              : "Your cart is empty"}
          &lt;/SheetDescription&gt;
        &lt;/SheetHeader&gt;

        &lt;div className="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3"&gt;
          {items.map((item) =&gt; (
            &lt;div key={item.id} className="flex items-start gap-3"&gt;
              &lt;div className="size-16 rounded-lg bg-muted shrink-0 overflow-hidden"&gt;
                &lt;img src={item.image} alt={item.name} className="size-full object-cover" /&gt;
              &lt;/div&gt;
              &lt;div className="flex-1 min-w-0"&gt;
                &lt;p className="text-sm font-medium leading-snug truncate"&gt;{item.name}&lt;/p&gt;
                &lt;p className="text-xs text-muted-foreground"&gt;{item.variant}&lt;/p&gt;
                &lt;p className="text-sm font-semibold mt-1"&gt;${item.price.toFixed(2)}&lt;/p&gt;
              &lt;/div&gt;
              &lt;div className="flex flex-col items-end gap-2 shrink-0"&gt;
                &lt;Button
                  variant="ghost"
                  size="icon-sm"
                  className="text-muted-foreground hover:text-destructive hover:bg-destructive/10! cursor-pointer"
                  onClick={() =&gt; updateQty(item.id, -item.qty)}
                &gt;
                  &lt;Trash2Icon size={14} /&gt;
                &lt;/Button&gt;
                &lt;ButtonGroup&gt;
                  &lt;Button variant="outline" size="icon-sm" className="cursor-pointer" onClick={() =&gt; updateQty(item.id, -1)}&gt;
                    &lt;MinusIcon /&gt;
                  &lt;/Button&gt;
                  &lt;ButtonGroupText className="px-2 text-sm min-w-7 justify-center"&gt;{item.qty}&lt;/ButtonGroupText&gt;
                  &lt;Button variant="outline" size="icon-sm" className="cursor-pointer" onClick={() =&gt; updateQty(item.id, 1)}&gt;
                    &lt;PlusIcon /&gt;
                  &lt;/Button&gt;
                &lt;/ButtonGroup&gt;
              &lt;/div&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;

        &lt;SheetFooter className="flex flex-col gap-3 px-4 pt-3 pb-5 border-t"&gt;
          &lt;div className="flex flex-col gap-1.5 w-full"&gt;
            &lt;div className="flex justify-between text-sm text-muted-foreground"&gt;
              &lt;span&gt;Subtotal&lt;/span&gt;
              &lt;span&gt;${subtotal.toFixed(2)}&lt;/span&gt;
            &lt;/div&gt;
            &lt;div className="flex justify-between text-sm text-muted-foreground"&gt;
              &lt;span&gt;Shipping&lt;/span&gt;
              &lt;span className="text-teal-400"&gt;Free&lt;/span&gt;
            &lt;/div&gt;
            &lt;Separator className="my-1" /&gt;
            &lt;div className="flex justify-between text-sm font-semibold"&gt;
              &lt;span&gt;Total&lt;/span&gt;
              &lt;span&gt;${subtotal.toFixed(2)}&lt;/span&gt;
            &lt;/div&gt;
          &lt;/div&gt;
          &lt;Button className="w-full cursor-pointer hover:bg-primary/80"&gt;Checkout&lt;/Button&gt;
          &lt;SheetClose render={&lt;Button variant="outline" className="w-full cursor-pointer" /&gt;}&gt;
            Continue Shopping
          &lt;/SheetClose&gt;
        &lt;/SheetFooter&gt;
      &lt;/SheetContent&gt;
    &lt;/Sheet&gt;
  );
};

export default ShoppingCartDemo;
</code></pre>
<p>Let's go through how this works.</p>
<h4 id="heading-1-derived-totals-instead-of-tracked-state">1. Derived totals instead of tracked state</h4>
<p><code>subtotal</code> and <code>totalCount</code> are calculated from <code>items</code> on every render, not stored in their own <code>useState</code>. If you tracked them separately, they'd drift out of sync the first time you updated <code>items</code> and forgot to update the count alongside them.</p>
<h4 id="heading-2-removing-an-item-is-just-zeroing-its-quantity">2. Removing an item is just zeroing its quantity</h4>
<pre><code class="language-javascript">const updateQty = (id, delta) =&gt; {
  setItems((prev) =&gt;
    prev
      .map((item) =&gt; (item.id === id ? { ...item, qty: item.qty + delta } : item))
      .filter((item) =&gt; item.qty &gt; 0)
  );
};
</code></pre>
<p>The trash icon calls <code>updateQty(item.id, -item.qty)</code>, which drops that item's quantity to zero. The <code>.filter()</code> step then removes it from the array. This means you don't need a separate "remove item" function that duplicates logic already living in <code>updateQty</code>.</p>
<h4 id="heading-3-render-prop-instead-of-aschild">3. <code>render</code> prop instead of <code>asChild</code></h4>
<p><code>SheetTrigger</code> and <code>SheetClose</code> use the <code>render</code> prop (<code>render={&lt;Button ... /&gt;}</code>) rather than wrapping children in an <code>asChild</code> pattern. This is the Base UI convention that Shadcn Space components use, and it keeps the button's accessibility behavior (focus states, keyboard handling) entire instead of overriding it with a custom wrapper.</p>
<h4 id="heading-4-fixed-header-and-footer-scrollable-middle">4. Fixed header and footer, scrollable middle</h4>
<pre><code class="language-javascript">&lt;SheetContent className="flex flex-col p-0 gap-0"&gt;
  &lt;SheetHeader className="border-b" /&gt;
  &lt;div className="flex-1 overflow-y-auto"&gt;{/* items */}&lt;/div&gt;
  &lt;SheetFooter className="border-t" /&gt;
&lt;/SheetContent&gt;
</code></pre>
<p><code>flex-1 overflow-y-auto</code> on the middle div is what keeps the header and footer fixed while the item list scrolls independently. Skip this, and a long cart pushes the checkout button off-screen.</p>
<h3 id="heading-live-preview"><strong>Live Preview:</strong></h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/dec70851-3b64-43d4-9611-f75165f7c4ca.gif" alt="dec70851-3b64-43d4-9611-f75165f7c4ca" style="display:block;margin:0 auto" width="1152" height="648" loading="lazy">

<h2 id="heading-how-to-build-the-filter-panel-sheet-sheet-04">How to Build the Filter Panel Sheet (sheet-04)</h2>
<h3 id="heading-what-the-filter-sheet-does">What the Filter Sheet Does</h3>
<p>The Filters button slides a panel in from the left with category checkboxes, a price range slider, a star rating filter, and an availability toggle. An active filter count shows on the trigger button itself, and clearing filters resets the state without closing the panel.</p>
<h3 id="heading-how-to-install-the-filter-sheet">How to Install the Filter Sheet</h3>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/sheet-04
</code></pre>
<p>The CLI copies the component into:</p>
<pre><code class="language-javascript">components/
  shadcn-space/
    sheet/
      sheet-04.tsx
</code></pre>
<h3 id="heading-the-component-code">The Component Code</h3>
<pre><code class="language-javascript">"use client";

import { useState } from "react";
import { SlidersHorizontalIcon, StarIcon } from "lucide-react";
import {
  Sheet,
  SheetTrigger,
  SheetContent,
  SheetHeader,
  SheetTitle,
  SheetDescription,
  SheetFooter,
  SheetClose,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";

const CATEGORIES = ["Watches", "Clothing", "Beauty", "Electronics", "Home &amp; Living"];
const RATINGS = [4, 3, 2, 1];
const AVAILABILITY = ["In Stock", "On Sale"];

const AdvancedFiltersDemo = () =&gt; {
  const [selectedCategories, setSelectedCategories] = useState([]);
  const [priceRange, setPriceRange] = useState([0, 1000]);
  const [selectedRating, setSelectedRating] = useState(null);
  const [selectedAvailability, setSelectedAvailability] = useState([]);

  const toggleCategory = (cat) =&gt;
    setSelectedCategories((prev) =&gt; (prev.includes(cat) ? prev.filter((c) =&gt; c !== cat) : [...prev, cat]));

  const toggleAvailability = (val) =&gt;
    setSelectedAvailability((prev) =&gt; (prev.includes(val) ? prev.filter((v) =&gt; v !== val) : [...prev, val]));

  const clearAll = () =&gt; {
    setSelectedCategories([]);
    setPriceRange([0, 1000]);
    setSelectedRating(null);
    setSelectedAvailability([]);
  };

  const activeCount =
    selectedCategories.length +
    (priceRange[0] !== 0 || priceRange[1] !== 1000 ? 1 : 0) +
    (selectedRating !== null ? 1 : 0) +
    selectedAvailability.length;

  return (
    &lt;Sheet&gt;
      &lt;SheetTrigger render={&lt;Button variant="outline" className="relative cursor-pointer gap-2" /&gt;}&gt;
        &lt;SlidersHorizontalIcon size={16} /&gt;
        Filters
        {activeCount &gt; 0 &amp;&amp; (
          &lt;Badge className="size-5 justify-center rounded-full p-0 text-[10px]"&gt;{activeCount}&lt;/Badge&gt;
        )}
      &lt;/SheetTrigger&gt;

      &lt;SheetContent side="left" className="flex flex-col p-0 gap-0"&gt;
        &lt;SheetHeader className="px-4 pt-5 pb-4 border-b"&gt;
          &lt;SheetTitle&gt;Filters&lt;/SheetTitle&gt;
          &lt;SheetDescription&gt;Narrow down products by your preferences.&lt;/SheetDescription&gt;
        &lt;/SheetHeader&gt;

        &lt;div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-5"&gt;
          &lt;div className="flex flex-col gap-3"&gt;
            &lt;p className="text-sm font-medium"&gt;Category&lt;/p&gt;
            {CATEGORIES.map((cat) =&gt; (
              &lt;div key={cat} className="flex items-center gap-2.5"&gt;
                &lt;Checkbox id={`cat-${cat}`} checked={selectedCategories.includes(cat)} onCheckedChange={() =&gt; toggleCategory(cat)} /&gt;
                &lt;Label htmlFor={`cat-${cat}`} className="text-sm font-normal cursor-pointer"&gt;{cat}&lt;/Label&gt;
              &lt;/div&gt;
            ))}
          &lt;/div&gt;

          &lt;Separator /&gt;

          &lt;div className="flex flex-col gap-4"&gt;
            &lt;div className="flex items-center justify-between"&gt;
              &lt;p className="text-sm font-medium"&gt;Price Range&lt;/p&gt;
              &lt;span className="text-xs text-muted-foreground"&gt;${priceRange[0]} -- ${priceRange[1]}&lt;/span&gt;
            &lt;/div&gt;
            &lt;Slider value={priceRange} onValueChange={(val) =&gt; setPriceRange(Array.isArray(val) ? [...val] : [val])} min={0} max={1000} step={10} /&gt;
          &lt;/div&gt;

          &lt;Separator /&gt;

          &lt;div className="flex flex-col gap-3"&gt;
            &lt;p className="text-sm font-medium"&gt;Rating&lt;/p&gt;
            {RATINGS.map((rating) =&gt; (
              &lt;div key={rating} className="flex items-center gap-2.5 cursor-pointer" onClick={() =&gt; setSelectedRating(selectedRating === rating ? null : rating)}&gt;
                &lt;Checkbox id={`rating-${rating}`} checked={selectedRating === rating} onCheckedChange={() =&gt; setSelectedRating(selectedRating === rating ? null : rating)} /&gt;
                &lt;Label htmlFor={`rating-${rating}`} className="flex items-center gap-1 text-sm font-normal cursor-pointer"&gt;
                  {Array.from({ length: rating }).map((_, i) =&gt; &lt;StarIcon key={i} size={13} className="fill-amber-400 text-amber-400" /&gt;)}
                  {Array.from({ length: 5 - rating }).map((_, i) =&gt; &lt;StarIcon key={i} size={13} className="text-muted-foreground/40" /&gt;)}
                  &lt;span className="ml-0.5 text-muted-foreground"&gt;&amp; up&lt;/span&gt;
                &lt;/Label&gt;
              &lt;/div&gt;
            ))}
          &lt;/div&gt;

          &lt;Separator /&gt;

          &lt;div className="flex flex-col gap-3"&gt;
            &lt;p className="text-sm font-medium"&gt;Availability&lt;/p&gt;
            {AVAILABILITY.map((val) =&gt; (
              &lt;div key={val} className="flex items-center gap-2.5"&gt;
                &lt;Checkbox id={`avail-${val}`} checked={selectedAvailability.includes(val)} onCheckedChange={() =&gt; toggleAvailability(val)} /&gt;
                &lt;Label htmlFor={`avail-${val}`} className="text-sm font-normal cursor-pointer"&gt;{val}&lt;/Label&gt;
              &lt;/div&gt;
            ))}
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;SheetFooter className="flex flex-row gap-2 px-4 pt-3 pb-5 border-t"&gt;
          &lt;Button variant="outline" className="flex-1 cursor-pointer" onClick={clearAll}&gt;Clear All&lt;/Button&gt;
          &lt;SheetClose render={&lt;Button className="flex-1 cursor-pointer hover:bg-primary/80" /&gt;}&gt;Apply Filters&lt;/SheetClose&gt;
        &lt;/SheetFooter&gt;
      &lt;/SheetContent&gt;
    &lt;/Sheet&gt;
  );
};

export default AdvancedFiltersDemo;
</code></pre>
<p>How this component works:</p>
<h4 id="heading-1-use-a-separate-state-for-each-filter-type">1. Use a separate state for each filter type</h4>
<p>Categories and availability use arrays because users can select multiple options. Rating stores a single value (or <code>null</code>) since only one rating can be selected. Price uses a two-value array (<code>[min, max]</code>) to store the selected range. This keeps each filter's state simple and matches how the UI works.</p>
<h4 id="heading-2-activecount-is-computed-not-tracked">2. <code>activeCount</code> is computed, not tracked</h4>
<p>Same reasoning as the cart badge above:</p>
<pre><code class="language-javascript">const activeCount =
  selectedCategories.length +
  (priceRange[0] !== 0 || priceRange[1] !== 1000 ? 1 : 0) +
  (selectedRating !== null ? 1 : 0) +
  selectedAvailability.length;
</code></pre>
<p>If you tracked this count manually, you'd eventually add a new filter, forget to update the count logic, and end up with a badge that lies to the user.</p>
<h4 id="heading-3-apply-filters-closes-the-panel-clear-all-doesnt">3. "Apply Filters" closes the panel, "Clear All" doesn't</h4>
<p><code>Clear All</code> calls <code>clearAll()</code> directly on a plain <code>Button</code>. <code>Apply Filters</code> is wrapped in <code>SheetClose</code>, so it resets nothing but closes the panel. A user clearing filters usually wants to keep the panel open to pick new ones. Closing on every action would force them to reopen it each time.</p>
<h4 id="heading-4-toggling-a-category-with-filter-and-spread">4. Toggling a category with <code>.filter()</code> and spread</h4>
<pre><code class="language-javascript">const toggleCategory = (cat) =&gt;
  setSelectedCategories((prev) =&gt; (prev.includes(cat) ? prev.filter((c) =&gt; c !== cat) : [...prev, cat]));
</code></pre>
<p>This one line handles both adding and removing a category from the array, based on whether it's already present. It's a pattern worth reusing anywhere you have a multi-select checkbox list backed by an array of strings.</p>
<h3 id="heading-live-preview"><strong>Live Preview:</strong></h3>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/e3850372-c172-4d49-b973-09192e80bc61.gif" alt="e3850372-c172-4d49-b973-09192e80bc61" style="display:block;margin:0 auto" width="1152" height="648" loading="lazy">

<h2 id="heading-quick-reference-table">Quick Reference Table</h2>
<table style="min-width:378px"><colgroup><col style="min-width:25px"><col style="width:80px"><col style="width:273px"></colgroup><tbody><tr><td><p><strong>Sheet</strong></p></td><td><p><strong>Identifier</strong></p></td><td><p><strong>Use Case</strong></p></td></tr><tr><td><p><strong>Shopping Cart Sheet</strong></p></td><td><p>sheet-03</p></td><td><p>Cart drawers, order summaries</p></td></tr><tr><td><p><strong>Filter Panel Sheet</strong></p></td><td><p>sheet-04</p></td><td><p>Product filters, search refinement panels</p></td></tr></tbody></table>

<p>To install either, swap the identifier in the CLI command:</p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/&lt;identifier&gt;
</code></pre>
<hr>
<h2 id="heading-key-concepts-for-using-shadcn-sheet">Key Concepts for Using Shadcn Sheet</h2>
<ul>
<li><p><strong>Use a Sheet for secondary workflows:</strong> It's ideal for filters, shopping carts, settings, navigation menus, and forms that shouldn't interrupt the main page.</p>
</li>
<li><p><strong>Keep the layout consistent:</strong> Use a fixed header, a scrollable content area (<code>flex-1 overflow-y-auto</code>), and a fixed footer so actions remain visible even with long content.</p>
</li>
<li><p><strong>Derive UI values from state:</strong> Counts, totals, and badges (such as active filters or cart items) should be calculated from state instead of being stored separately.</p>
</li>
<li><p><strong>Close the Sheet after completing an action:</strong> Actions like <strong>Apply Filters</strong>, <strong>Save</strong>, or <strong>Checkout</strong> should close the Sheet. Actions like <strong>Clear Filters</strong> or <strong>Reset</strong> should keep it open so users can continue making changes.</p>
</li>
<li><p><strong>Choose the right state structure:</strong> Use arrays for multi-select options (such as categories or availability), a single value (or <code>null</code>) for single-select options (such as ratings), and a two-value array (<code>[min, max]</code>) for range filters like price.</p>
</li>
<li><p><strong>Keep actions easy to reach:</strong> Place primary actions like <strong>Apply</strong>, <strong>Save</strong>, or <strong>Checkout</strong> in the footer so they remain accessible while the content scrolls.</p>
</li>
<li><p><strong>Avoid overloading the Sheet:</strong> If the workflow is long, requires multiple steps, or needs the user's full attention, consider using a dedicated page instead of a Sheet.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this guide, we built two practical Shadcn Sheet components, a shopping cart and a filter panel, to demonstrate the patterns you'll use most often in React applications. Along the way, we covered layout best practices, state management, derived values, and interaction patterns that make Sheets feel intuitive and reliable.</p>
<p>These examples are more than just demos. They provide a reusable foundation for building settings drawers, mobile navigation, notification panels, and many other side panel experiences. By following these patterns, you can create Shadcn Sheet components that are clean, responsive, and easy to maintain as your application grows.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://shadcnspace.com/"><strong>Shadcn UI</strong></a>: Component library and documentation home</p>
</li>
<li><p><a href="https://shadcnspace.com/components/sheet"><strong>Shadcn Sheet Components</strong></a>: All Sheet variants are available in Radix and Base UI</p>
</li>
<li><p><a href="https://shadcnspace.com/components"><strong>Shadcn Components</strong></a>: Full component library with all available categories</p>
</li>
<li><p><a href="https://shadcnspace.com/admin-dashboard"><strong>Shadcn Dashboard</strong></a>: Ready-to-use admin dashboard built on the same system</p>
</li>
<li><p><a href="https://shadcnspace.com/blocks/dashboard-ui/sidebars"><strong>Shadcn Sidebar</strong></a>: If you're looking for a shadcn sidebar to pair with these panels, this is where to start</p>
</li>
<li><p><a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli"><strong>How to Use the Shadcn CLI</strong></a>: Getting started guide for the CLI and registry setup</p>
</li>
<li><p><a href="https://shadcnspace.com/mcp"><strong>Shadcn MCP Server</strong></a>: Browse and install components from your editor</p>
</li>
<li><p><a href="https://youtu.be/mMlxAmJlbMI?si=4f37Bg9076ldMllL"><strong>Video Walkthrough: How to Use Shadcn Space with MCP Server</strong></a></p>
</li>
<li><p><a href="https://ui.shadcn.com/docs"><strong>Official shadcn/ui Documentation</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Defend Your JavaScript App Against Unsafe Data with TypeScript Guard Utilities ]]>
                </title>
                <description>
                    <![CDATA[ Picture this: you hit an API endpoint, and you get an API response back. You pass the data straight into your application, and everything looks fine in development. Your mock data is clean, your types ]]>
                </description>
                <link>https://www.freecodecamp.org/news/defend-your-js-app-against-unsafe-data-with-typescript-guard-utilities/</link>
                <guid isPermaLink="false">6a4576be7808ec6de20de8ff</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Programming Tips ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Kelechi Apugo ]]>
                </dc:creator>
                <pubDate>Wed, 01 Jul 2026 20:21:18 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/45694ec7-239d-4f58-a597-9b2145bc02f5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Picture this: you hit an API endpoint, and you get an API response back. You pass the data straight into your application, and everything looks fine in development. Your mock data is clean, your types line up, and everything checks out.</p>
<p>Then your code hits production. A field from the API endpoint comes back as <code>null</code> instead of a string. You were expecting an array, and it comes back as <code>undefined</code>, expecting an object and receiving a <code>number</code>. Suddenly, you're faced with an error screen, a crashed UI, or worse, silent data corruption that nobody notices until a user complains.</p>
<p>This is a common and preventable bug in JavaScript development. The fix doesn't require a third-party library or a complete architecture overhaul. It requires a small set of utility functions and the discipline to use them when needed.</p>
<p>This article shows you how to build a resilient application using four TypeScript guard utilities that'll make your codebase more reliable: <code>safeArray</code>, <code>safeString</code>, <code>safeNumber</code>, and <code>safeObject</code>. The utilities are framework-agnostic, so whether you're working in React, plain JavaScript, or anything in between, you can drop them straight into your codebase.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-problem">The Problem</a></p>
</li>
<li><p><a href="#heading-why-this-problem-exists">Why This Problem Exists</a></p>
</li>
<li><p><a href="#heading-the-solution-safe-access-utilities">The Solution: Safe Access Utilities</a></p>
</li>
<li><p><a href="#heading-how-each-utility-works">How Each Utility Works</a></p>
</li>
<li><p><a href="#heading-how-to-use-them-in-practice">How to Use Them in Practice</a></p>
</li>
<li><p><a href="#heading-best-practices">Best Practices</a></p>
</li>
<li><p><a href="#heading-things-to-avoid">Things to Avoid</a></p>
</li>
<li><p><a href="#heading-bonus-combine-them-into-a-safedata-helper">Bonus: Combine Them into a safeData Helper</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before diving in, you should have:</p>
<ul>
<li><p>A working knowledge of TypeScript. You don't need to be an expert, but you should be comfortable with types, interfaces, and generics</p>
</li>
<li><p>Familiarity with JavaScript and its built-in type-checking methods, like <code>typeof</code> and <code>Array.isArray</code>.</p>
</li>
</ul>
<h2 id="heading-the-problem">The Problem</h2>
<p>JavaScript is a loosely typed language. It will let you call <code>.map()</code> on something that isn't an array, access properties on <code>null</code>, and do arithmetic with <code>NaN</code>. All of this, without throwing an error until it's too late. The language doesn't push back. It just breaks quietly.</p>
<p>TypeScript helps, but only up to a point. It checks types at compile time, not at runtime. So when external data arrives from an API, a form submission, local storage, or a third-party SDK, TypeScript has already left the building. Whatever your interface says, the actual value at runtime is whatever JavaScript received.</p>
<p>Here's what that looks like in practice:</p>
<pre><code class="language-typescript">// This looks fine. It is not fine.
type User = {
  id: number;
  name: string;
  tags: string[];
};

function displayUser(user: User) {
  const upperName = user.name.toUpperCase();
  const tagList = user.tags.map((tag) =&gt; `#${tag}`);
  return { upperName, tagList };
}
</code></pre>
<p>If <code>user.name</code> comes back as <code>null</code>, calling <code>.toUpperCase()</code> crashes your application. If <code>user.tags</code> is <code>undefined</code>, calling <code>.map()</code> crashes your application, too. Both scenarios are entirely possible when you're consuming a real API, and TypeScript won't warn you because you told it to trust the type.</p>
<p>Wait! I hear you saying, "I can use <code>optional chaining</code> to stop my app from crashing". This is correct, like the example below:</p>
<pre><code class="language-typescript">// This looks better. But...
type User = {
  id: number;
  name: string;
  tags: string[];
};

function displayUser(user: User) {
  const upperName = user?.name?.toUpperCase?.();
  const tagList = (user?.tags || [])?.map((tag) =&gt; `#${tag}`); 
  return { upperName, tagList };
}
</code></pre>
<p>But there are issues with the above approach. Firstly, <code>upperName</code> will return <code>undefined</code> if <code>user.name</code> isn't a string. Secondly, the <code>user?.tag || []</code> guards for <code>undefined</code> and <code>null</code> values alone. What if an object gets returned? <code>{...}?.map(...)</code>? Do you see the real issue now?</p>
<p>So <code>user?.name?.toUpperCase?.()</code> safely handles cases where <code>user</code>, <code>name</code>, or even <code>toUpperCase</code> itself might not exist. This is handy when dealing with uncertain data shapes, but it doesn't handle data mismatch.</p>
<h2 id="heading-why-this-problem-exists">Why This Problem Exists</h2>
<p>The blame sits squarely with JavaScript's type system, or rather, its lack of one.</p>
<p>JavaScript has a handful of primitive types and a few rules that seem reasonable until you look at them closely. For example, <code>typeof null</code> returns <code>"object"</code>, <code>typeof []</code> also returns <code>"object"</code>, and <code>typeof NaN</code> returns <code>"number"</code>. These aren't edge cases. They're the language.</p>
<p>Here's a quick illustration of how easily JavaScript misleads you:</p>
<pre><code class="language-javascript">typeof null;        // "object" — not "null"
typeof [];          // "object" — not "array"
typeof NaN;         // "number" — NaN is technically a number
Array.isArray([]);  // true — this is the correct check
isNaN("hello");     // true — because "hello" coerces to NaN
Number.isNaN("hello"); // false — this is the correct check
</code></pre>
<p>TypeScript layers a static type system on top of this, catching many mistakes before your code runs. But static analysis only works on code you've already written. The moment data crosses the network boundary or comes from <code>localStorage</code>, a URL parameter, a third-party script, or any source outside your codebase, TypeScript's guarantees stop.</p>
<p>When you write something like this:</p>
<pre><code class="language-typescript">const data = await response.json() as User;
</code></pre>
<p>You're not validating anything. You're telling the TypeScript compiler, "I promise this is a <code>User</code>" . The compiler accepts that promise and stops checking. But if the API returns <code>null</code> for a field, sends a string where you expected a number, or omits a property entirely, JavaScript will proceed anyway and your code will break at the first operation that assumes otherwise.</p>
<p>This gap between "what TypeScript thinks the data is" and "what the data actually is at runtime" is where most production data bugs live. The fix is to stop trusting the type assertion and start validating the data yourself.</p>
<h2 id="heading-the-solution-safe-access-utilities">The Solution: Safe Access Utilities</h2>
<p>The fix is to validate data at the boundary. The moment the expected data enters your application, check it before you pass it anywhere else.</p>
<p>These four functions do exactly that:</p>
<pre><code class="language-typescript">export function safeArray&lt;T&gt;(prop: unknown): T[] {
  if (Array.isArray(prop)) {
    return prop as T[];
  } else {
    return [] as T[];
  }
}

export function safeString(prop: unknown, fallback = ""): string {
  if (typeof prop === "string") {
    return prop;
  } else {
    return fallback;
  }
}

export function safeNumber(prop: unknown, fallback = 0): number {
  if (typeof prop === "number" &amp;&amp; !isNaN(prop)) {
    return prop;
  } else {
    return fallback;
  }
}

export function safeObject&lt;T extends object&gt;(
  prop: unknown,
  fallback = {} as T,
): T {
  if (prop !== null &amp;&amp; typeof prop === "object" &amp;&amp; !Array.isArray(prop)) {
    return prop as T;
  }
  return fallback;
}
</code></pre>
<p>Each function accepts <code>unknown</code>, which forces you to validate the value before using it. Each one returns a safe default if the input isn't what you expected. No crashes, no silent <code>undefined</code>, and no cryptic runtime errors.</p>
<p>You can drop these into any JavaScript or TypeScript project: React app, a Node.js API, a vanilla TypeScript module, or wherever you're handling external data.</p>
<h2 id="heading-how-each-utility-works">How Each Utility Works</h2>
<h3 id="heading-safearray"><code>safeArray</code></h3>
<pre><code class="language-typescript">export function safeArray&lt;T&gt;(prop: unknown): T[] {
  if (Array.isArray(prop)) {
    return prop as T[];
  } else {
    return [] as T[];
  }
}
</code></pre>
<p>This checks whether <code>prop</code> is actually an array using <code>Array.isArray</code>. If it is, you get it back typed as <code>T[]</code>. If it's anything other than an array like <code>null</code>, <code>undefined</code>, a string, or whatever, you get back an empty array.</p>
<p>This matters because of the JavaScript quirk you saw above: <code>typeof []</code> returns <code>"object"</code>, which means a naive <code>typeof</code> check wouldn't catch this. <code>Array.isArray</code> handles it correctly.</p>
<h3 id="heading-safestring"><code>safeString</code></h3>
<pre><code class="language-typescript">export function safeString(prop: unknown, fallback = ""): string {
  if (typeof prop === "string") {
    return prop;
  } else {
    return fallback;
  }
}
</code></pre>
<p>This function uses <code>typeof</code> to confirm that the value is a string. The optional <code>fallback</code> parameter lets you specify a meaningful default. For example, <code>"Unknown"</code> instead of an empty string, when displaying a user's name.</p>
<h3 id="heading-safenumber"><code>safeNumber</code></h3>
<pre><code class="language-typescript">export function safeNumber(prop: unknown, fallback = 0): number {
  if (typeof prop === "number" &amp;&amp; !isNaN(prop)) {
    return prop;
  } else {
    return fallback;
  }
}
</code></pre>
<p>The key detail here is <code>!isNaN(prop)</code>. Because <code>typeof NaN === "number"</code> is true in JavaScript, skipping this check means you could return <code>NaN</code> and cause downstream calculation failures. This function guards against that.</p>
<h3 id="heading-safeobject"><code>safeObject</code></h3>
<pre><code class="language-typescript">export function safeObject&lt;T extends object&gt;(
  prop: unknown,
  fallback = {} as T,
): T {
  if (prop !== null &amp;&amp; typeof prop === "object" &amp;&amp; !Array.isArray(prop)) {
    return prop as T;
  }
  return fallback;
}
</code></pre>
<p>This one requires three conditions due to JavaScript's quirks. <code>typeof null === "object"</code> is true. <code>typeof [] === "object"</code> is also true. So this function explicitly excludes both. What you get back is guaranteed to be a plain object and nothing else.</p>
<h2 id="heading-how-to-use-them-in-practice">How to Use Them in Practice</h2>
<h3 id="heading-normalising-api-responses-plain-typescript">Normalising API Responses (Plain TypeScript)</h3>
<p>The best place to use these utilities is in the function that processes your API response before the data reaches any other part of your application. This works the same way, whether in a React app, a Node.js service, or a plain TypeScript module.</p>
<pre><code class="language-typescript">// lib/users.ts
import { safeArray, safeString, safeNumber, safeObject } from "@/utils/safe";

type User = {
  id: number;
  name: string;
  email: string;
  tags: string[];
};

function normaliseUser(raw: unknown): User {
  const obj = safeObject&lt;Record&lt;string, unknown&gt;&gt;(raw);

  return {
    id: safeNumber(obj.id),
    name: safeString(obj.name, "Unknown User"),
    email: safeString(obj.email),
    tags: safeArray&lt;string&gt;(obj.tags),
  };
}

async function fetchUser(id: string): Promise&lt;User&gt; {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return normaliseUser(data);
}
</code></pre>
<p>By the time your code receives the <code>User</code> object, every field is guaranteed to be the type you declared. Nothing downstream has to wonder whether <code>name</code> might be <code>null</code> or <code>tags</code> might be <code>undefined</code>.</p>
<h3 id="heading-in-a-react-component-defensive-rendering">In a React Component (Defensive Rendering)</h3>
<p>Sometimes you receive data directly in a component, from props, context, or a query result, and you don't control normalisation upstream. In that case, wrap the values at the point of use.</p>
<pre><code class="language-typescript">import { safeArray, safeString, safeNumber, safeObject } from "@/utils/safe";

type ProductProps = {
  product: unknown;
};

function ProductCard({ product }: ProductProps) {
  const p = safeObject&lt;Record&lt;string, unknown&gt;&gt;(product);
  const name = safeString(p.name, "Unnamed Product");
  const price = safeNumber(p.price);
  const tags = safeArray&lt;string&gt;(p.tags);

  return (
    &lt;div className="product-card"&gt;
      &lt;h3&gt;{name}&lt;/h3&gt;
      &lt;p&gt;${price.toFixed(2)}&lt;/p&gt;
      &lt;ul&gt;
        {tags.map((tag) =&gt; (
          &lt;li key={tag}&gt;{tag}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Even if <code>product</code> arrives as <code>null</code> or a completely unexpected shape, this component will render a fallback state instead of crashing.</p>
<h3 id="heading-with-react-query">With React Query</h3>
<p>If you're using React Query, you can normalise data inside the <code>select</code> option, which transforms the raw API response before it reaches your component.</p>
<pre><code class="language-typescript">import { useQuery } from "@tanstack/react-query";
import { safeArray, safeString, safeNumber, safeObject } from "@/utils/safe";

type Order = {
  id: number;
  status: string;
  total: number;
  items: string[];
};

function normaliseOrder(raw: unknown): Order {
  const obj = safeObject&lt;Record&lt;string, unknown&gt;&gt;(raw);
  return {
    id: safeNumber(obj.id),
    status: safeString(obj.status, "pending"),
    total: safeNumber(obj.total),
    items: safeArray&lt;string&gt;(obj.items),
  };
}

function useOrder(orderId: string) {
  return useQuery({
    queryKey: ["order", orderId],
    queryFn: () =&gt;
      fetch(`/api/orders/${orderId}`).then((res) =&gt; res.json()),
    select: normaliseOrder,
  });
}
</code></pre>
<p>The <code>select</code> callback runs after the query resolves and before the data is cached. Your <code>useOrder</code> hook always returns a properly shaped <code>Order</code>, regardless of what the API actually sent back.</p>
<h3 id="heading-with-a-react-context-provider">With a React Context Provider</h3>
<p>Context is a place where unsafe data can silently propagate through your entire component tree. Normalise it at the provider level so every consumer is protected.</p>
<pre><code class="language-typescript">import { createContext, useContext, useEffect, useState } from "react";
import { safeArray, safeString, safeObject } from "@/utils/safe";

type AppConfig = {
  theme: string;
  features: string[];
};

const defaultConfig: AppConfig = {
  theme: "light",
  features: [],
};

const ConfigContext = createContext&lt;AppConfig&gt;(defaultConfig);

function ConfigProvider({ children }: { children: React.ReactNode }) {
  const [config, setConfig] = useState&lt;AppConfig&gt;(defaultConfig);

  useEffect(() =&gt; {
    fetch("/api/config")
      .then((res) =&gt; res.json())
      .then((raw: unknown) =&gt; {
        const obj = safeObject&lt;Record&lt;string, unknown&gt;&gt;(raw);
        setConfig({
          theme: safeString(obj.theme, "light"),
          features: safeArray&lt;string&gt;(obj.features),
        });
      });
  }, []);

  return (
    &lt;ConfigContext.Provider value={config}&gt;{children}&lt;/ConfigContext.Provider&gt;
  );
}

export function useConfig() {
  return useContext(ConfigContext);
}
</code></pre>
<p>One normalisation step at the provider level protects every component that consumes the context.</p>
<h3 id="heading-in-a-nodejs-api-route">In a Node.js API Route</h3>
<p>These utilities are just as useful on the backend. If your Node.js API receives a request body, you can't trust that the client sent what you expected. Validate it at the point of entry.</p>
<pre><code class="language-typescript">// routes/orders.ts (Express)
import { safeArray, safeString, safeNumber, safeObject } from "../utils/safe";

type OrderPayload = {
  userId: number;
  notes: string;
  itemIds: number[];
};

function parseOrderPayload(raw: unknown): OrderPayload {
  const obj = safeObject&lt;Record&lt;string, unknown&gt;&gt;(raw);
  return {
    userId: safeNumber(obj.userId),
    notes: safeString(obj.notes),
    itemIds: safeArray&lt;number&gt;(obj.itemIds),
  };
}

app.post("/orders", (req, res) =&gt; {
  const payload = parseOrderPayload(req.body);

  if (!payload.userId) {
    return res.status(400).json({ error: "userId is required" });
  }

  // proceed with validated payload
});
</code></pre>
<p>The same four utilities, the same pattern. Just a different runtime environment.</p>
<h2 id="heading-best-practices">Best Practices</h2>
<p>As with anything, there are some best practices that'll help you use these utilities well and correctly.</p>
<p>First, normalise at the boundary, not inside every function. The best place to call these utilities is in your data-fetching layer, API handlers, or integration points, just once, before the data spreads. If you're calling <code>safeString</code> in five different places for the same field, that's a sign the normalisation belongs upstream.</p>
<p>Second, use meaningful fallbacks. The default fallbacks (empty string, <code>0</code>, empty array, and empty object) are safe, but sometimes misleading. For a user's display name, <code>safeString(name, "Anonymous")</code> is more informative than <code>safeString(name)</code>. Think about what makes sense for each field in your domain.</p>
<p>Third, keep your type definitions honest. If a field can realistically be <code>null</code> or <code>undefined</code> from your data source, reflect that in your types and use these utilities to handle it. Typing a field as <code>string</code> when it might be <code>null</code> just papers over the problem. These utilities work best when your types reflect the reality of what you receive.</p>
<p>Finally, create a normalisation module. Put all your normaliser functions in one place, for example, <code>src/lib/normalise.ts</code>. This keeps the defensive logic centralised, easy to test, and out of your application logic.</p>
<h2 id="heading-things-to-avoid">Things to Avoid</h2>
<p>Likewise, there are some practices you should avoid.</p>
<p>First, don't use these utilities as a substitute for a proper data contract. If your entire codebase is wrapping every value in <code>safeString</code> because your data sources are wildly inconsistent, the real fix is a contract, an OpenAPI spec, a shared schema, Zod validation, or at minimum, documented response shapes. These utilities handle edge cases and runtime surprises, not systemic chaos.</p>
<p>Second, don't skip the <code>safeObject</code> wrapper. It's tempting to cast straight to <code>any</code> and access properties directly. Avoid this. The <code>as any</code> cast defeats TypeScript entirely, and accessing properties on an <code>unknown</code> value will cause a compile error anyway. Use <code>safeObject</code> to unwrap the value first, then access its fields safely.</p>
<p>Next, don't chain these utilities without extracting intermediate values. Something like <code>safeString(safeArray(raw)[0])</code> might seem compact, but it's harder to read and debug. Extract intermediate values into clearly named variables instead.</p>
<p>And finally, don't skip validation just because you control the data source. "I wrote the API, so I know what it returns" is a reasonable position right up until a schema migration, a nullable column addition, or an unconsidered edge case proves otherwise. Trust the utilities, not your memory.</p>
<h2 id="heading-bonus-combine-them-into-a-safedata-helper">Bonus: Combine Them into a <code>safeData</code> Helper</h2>
<p>If you find yourself calling all four utilities together frequently, which you will once you start normalising API responses consistently, you can compose them into a single fluent helper.</p>
<pre><code class="language-typescript">// utils/safeData.ts
import { safeArray, safeString, safeNumber, safeObject } from "./safe";

type SafeDataAccessors = {
  string: (key: string, fallback?: string) =&gt; string;
  number: (key: string, fallback?: number) =&gt; number;
  array: &lt;T&gt;(key: string) =&gt; T[];
  object: &lt;T extends object&gt;(key: string, fallback?: T) =&gt; T;
};

export function safeData(raw: unknown): SafeDataAccessors {
  const obj = safeObject&lt;Record&lt;string, unknown&gt;&gt;(raw);

  return {
    string: (key, fallback = "") =&gt; safeString(obj[key], fallback),
    number: (key, fallback = 0) =&gt; safeNumber(obj[key], fallback),
    array: &lt;T&gt;(key: string) =&gt; safeArray&lt;T&gt;(obj[key]),
    object: &lt;T extends object&gt;(key: string, fallback = {} as T) =&gt;
      safeObject&lt;T&gt;(obj[key], fallback),
  };
}
</code></pre>
<p>Your normalisation functions then read cleanly, whether you're in a React hook, an Express route, or anywhere else:</p>
<pre><code class="language-typescript">import { safeData } from "@/utils/safeData";

function normaliseUser(raw: unknown) {
  const d = safeData(raw);
  return {
    id: d.number("id"),
    name: d.string("name", "Unknown User"),
    email: d.string("email"),
    tags: d.array&lt;string&gt;("tags"),
  };
}
</code></pre>
<p>This is a thin abstraction: no magic, just less repetition. Use it if your normalisation functions are getting verbose. Skip it if the direct utility calls are clear enough for your team.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>JavaScript's loose type system and TypeScript's compile-time-only guarantees leave a gap at every data boundary. External data — from APIs, request bodies, local storage, third-party scripts — arrives at runtime with no guarantee it matches the shape you declared. These four utilities close that gap.</p>
<p><code>safeArray</code>, <code>safeString</code>, <code>safeNumber</code>, and <code>safeObject</code> each accept <code>unknown</code>, validate the actual type, and return a safe fallback if the value isn't what you expected. They work in React components, Node.js routes, custom hooks, context providers, and any other JavaScript or TypeScript context where data enters your application.</p>
<p>The pattern is simple: validate at the boundary, trust inside. Normalise your data once, at the point it enters your codebase, and everything downstream can focus on its actual job instead of defending against bad inputs.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Reliable SSE Client in TypeScript ]]>
                </title>
                <description>
                    <![CDATA[ When you build a feature that streams data, like an AI chat response or a live notification feed, the network is rarely as cooperative as fetch makes it look. Connections drop, proxies buffer response ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-reliable-sse-client-in-typescript/</link>
                <guid isPermaLink="false">6a3db0651016f6a6b4bd2a89</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ streaming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SSE ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ timothy ogbemudia ]]>
                </dc:creator>
                <pubDate>Thu, 25 Jun 2026 22:49:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3c13d795-15e8-452a-b490-89528d58efd2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When you build a feature that streams data, like an AI chat response or a live notification feed, the network is rarely as cooperative as <code>fetch</code> makes it look.</p>
<p>Connections drop, proxies buffer responses, and mobile networks switch from WiFi to cellular mid-stream. If your streaming code doesn't plan for this, the user sees a response that just stops, with no error and no recovery.</p>
<p>In this article, you'll use an open source TypeScript library called <a href="https://github.com/glamboyosa/ore">Ore</a> as a practical example of how to build a streaming client that handles real-world network conditions: automatic retries, the official Server-Sent Events (SSE) parsing spec, and clean integration with React and React Server Components.</p>
<p>By the end, you'll understand how async generators, the Fetch API, and the SSE spec fit together to build something far more reliable than a basic <code>fetch</code> and <code>response.body.getReader()</code> loop.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-you-will-learn">What You Will Learn</a></p>
</li>
<li><p><a href="#heading-what-is-server-sent-events">What Is Server-Sent Events?</a></p>
</li>
<li><p><a href="#heading-why-build-a-custom-streaming-client">Why Build a Custom Streaming Client?</a></p>
</li>
<li><p><a href="#heading-how-to-stream-raw-chunks-with-an-async-generator">How to Stream Raw Chunks with an Async Generator</a></p>
</li>
<li><p><a href="#heading-how-to-parse-server-sent-events-by-hand">How to Parse Server-Sent Events by Hand</a></p>
</li>
<li><p><a href="#heading-how-to-implement-reconnection-with-last-event-id">How to Implement Reconnection with Last-Event-ID</a></p>
</li>
<li><p><a href="#heading-how-to-handle-retries-with-backoff">How to Handle Retries with Backoff</a></p>
</li>
<li><p><a href="#heading-how-to-use-this-with-react">How to Use This with React</a></p>
</li>
<li><p><a href="#heading-how-to-use-this-with-react-server-components">How to Use This with React Server Components</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should have:</p>
<ul>
<li><p>A working understanding of TypeScript</p>
</li>
<li><p>Familiarity with <code>fetch</code>, <code>ReadableStream</code>, and <code>async</code>/<code>await</code></p>
</li>
<li><p>Basic knowledge of React (for the React-specific sections)</p>
</li>
</ul>
<h2 id="heading-what-you-will-learn">What You Will Learn</h2>
<ul>
<li><p>How to stream raw text or bytes from a <code>fetch</code> response using async generators</p>
</li>
<li><p>How to parse the Server-Sent Events spec by hand, field by field</p>
</li>
<li><p>How to implement automatic reconnection with <code>Last-Event-ID</code> so you don't lose events</p>
</li>
<li><p>How to handle retries with exponential backoff</p>
</li>
<li><p>How to integrate a streaming client with React state and React Server Components</p>
</li>
</ul>
<h2 id="heading-what-is-server-sent-events">What Is Server-Sent Events?</h2>
<p>Server-Sent Events (SSE) is a web standard for one-way streaming from server to client over a single HTTP connection. Unlike WebSockets, it's plain HTTP, which means it works through existing infrastructure like load balancers and proxies without special configuration.</p>
<p>An SSE response looks like this on the wire:</p>
<pre><code class="language-plaintext">event: update
id: 42
data: {"status": "processing"}

event: update
id: 43
data: {"status": "complete"}
</code></pre>
<p>Each event is separated by a blank line. The <code>data</code> field carries the payload, <code>event</code> names the event type, and <code>id</code> lets the client track its position in the stream for reconnection.</p>
<p>The browser has a built-in <code>EventSource</code> API for this, but it has real limitations: no custom headers, no POST requests, and inconsistent reconnection behavior across browsers. For anything beyond the simplest case, you often need to parse the stream yourself.</p>
<h2 id="heading-why-build-a-custom-streaming-client">Why Build a Custom Streaming Client?</h2>
<p>Many streaming use cases, like AI chat responses, don't use the SSE spec at all. They're just raw chunks of text arriving over time. Other cases, like live notifications, genuinely benefit from the structure SSE provides: named events, IDs for resumption, and a server-controlled retry interval.</p>
<p>Ore handles both with two separate functions:</p>
<ul>
<li><p><code>stream()</code> for raw text or byte streaming, with no assumptions about format</p>
</li>
<li><p><code>streamSSE()</code> for spec-compliant SSE parsing</p>
</li>
</ul>
<p>Both are async generators, so consuming either looks the same from the call site:</p>
<pre><code class="language-typescript">for await (const chunk of stream("https://api.example.com/chat")) {
  console.log(chunk);
}
</code></pre>
<h2 id="heading-how-to-stream-raw-chunks-with-an-async-generator">How to Stream Raw Chunks with an Async Generator</h2>
<p>The simplest case is streaming raw text. This is useful for AI responses or log tails where there's no event structure, just a sequence of bytes arriving over time.</p>
<p>Here's the core of <code>stream()</code>:</p>
<pre><code class="language-typescript">export async function* stream(
  url: string,
  options?: StreamOptions
): AsyncGenerator&lt;string | Uint8Array, void, unknown&gt; {
  const { headers, retries = 3, signal, decode = true } = options || {};

  let retryCount = 0;

  while (retryCount &lt;= retries) {
    try {
      const response = await fetch(url, { method: "GET", headers, signal });

      if (!response.body) {
        throw new Error("Response body is null");
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();

      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          yield decode ? decoder.decode(value, { stream: true }) : value;
        }
      } finally {
        reader.releaseLock();
      }

      return;
    } catch (error: any) {
      if (signal?.aborted) throw error;
      retryCount++;
      if (retryCount &gt; retries) {
        throw new Error(`Max retries exceeded. Last error: ${error.message}`);
      }
      await new Promise((r) =&gt; setTimeout(r, 1000 * retryCount));
    }
  }
}
</code></pre>
<p>A few design decisions are worth calling out.</p>
<p>The function is an async generator (<code>async function*</code>), so the caller can use <code>for await...of</code> instead of managing a reader and a loop manually. That's the difference between exposing a raw <code>ReadableStream</code> and exposing something pleasant to consume.</p>
<p>The <code>finally</code> block always releases the reader lock, even if the loop exits early through a <code>break</code> or an exception. Forgetting this is a common source of stream leaks.</p>
<p>The retry loop only catches errors from the <code>fetch</code> call and the read loop. If the <code>AbortSignal</code> was the cause of the failure, it rethrows immediately rather than retrying, since retrying a deliberate cancellation makes no sense.</p>
<h2 id="heading-how-to-parse-server-sent-events-by-hand">How to Parse Server-Sent Events by Hand</h2>
<p>The SSE spec is a simple text format, but parsing it correctly means handling several edge cases: events split across multiple data lines, comment lines starting with a colon, fields with no value, and incomplete lines at the end of a chunk.</p>
<p>Here's the core state machine inside <code>streamSSE()</code>:</p>
<pre><code class="language-typescript">let buffer = "";
let currentEvent: Partial&lt;SSEEvent&gt; = { data: "", event: null, id: null };
let hasData = false;

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split(/\r\n|\r|\n/);
  buffer = lines.pop() || ""; // keep the last incomplete line for the next chunk

  for (const line of lines) {
    if (line === "") {
      if (hasData) {
        const event: SSEEvent = {
          id: currentEvent.id ?? lastEventId,
          event: currentEvent.event ?? null,
          data: currentEvent.data!.endsWith("\n")
            ? currentEvent.data!.slice(0, -1)
            : currentEvent.data!,
          retry: currentEvent.retry,
        };
        if (event.id) lastEventId = event.id;
        yield event;
        currentEvent = { data: "", event: null, id: null };
        hasData = false;
      }
      continue;
    }

    if (line.startsWith(":")) continue; // comment line, ignore

    const colonIndex = line.indexOf(":");
    const field = colonIndex === -1 ? line : line.slice(0, colonIndex);
    let valueStr = colonIndex === -1 ? "" : line.slice(colonIndex + 1);
    if (valueStr.startsWith(" ")) valueStr = valueStr.slice(1);

    switch (field) {
      case "data":
        currentEvent.data += valueStr + "\n";
        hasData = true;
        break;
      case "event":
        currentEvent.event = valueStr;
        break;
      case "id":
        if (valueStr.indexOf("\0") === -1) currentEvent.id = valueStr;
        break;
      case "retry":
        const retry = parseInt(valueStr, 10);
        if (!isNaN(retry)) retryInterval = retry;
        break;
    }
  }
}
</code></pre>
<p>A network chunk doesn't respect line boundaries. A single <code>read()</code> call might end mid-line, so the last, possibly incomplete line is held back in <code>buffer</code> and prepended to the next chunk rather than processed early. This is the part of SSE parsing that's easy to get wrong if you reach for a naïve <code>response.text()</code> and a string split.</p>
<p>The blank line is what ends an event. SSE events don't have a fixed-length header. The spec says a blank line marks the boundary, so the parser only yields an event once it has seen one.</p>
<p>The <code>id</code> field is rejected outright if it contains a null byte, per the spec. That's a small detail that almost no hand-rolled implementation gets right on the first try.</p>
<h2 id="heading-how-to-implement-reconnection-with-last-event-id">How to Implement Reconnection with Last-Event-ID</h2>
<p>This is the part of SSE that gives it a real advantage over a plain <code>fetch</code> stream: built-in support for resuming after a disconnect without losing your place.</p>
<pre><code class="language-typescript">let lastEventId: string | null = null;

while (retryCount &lt;= retries) {
  const headers = { ...customHeaders };
  if (lastEventId) {
    (headers as any)["Last-Event-ID"] = lastEventId;
  }

  const response = await fetch(url, { method: "GET", headers, signal });
  // ... read and parse events, updating lastEventId as they arrive
}
</code></pre>
<p>Every time an event with an <code>id</code> field arrives, <code>lastEventId</code> is updated. If the connection drops and the client reconnects, it sends <code>Last-Event-ID</code> in the request headers. A well-behaved server can use that header to resume the stream from the right point instead of replaying everything or skipping ahead.</p>
<p>This only works if the server actually honors the header, so it's a contract between client and server, not something the client can guarantee alone. But having the client track and send it correctly is the necessary half of that contract.</p>
<h2 id="heading-how-to-handle-retries-with-backoff">How to Handle Retries with Backoff</h2>
<p>Both <code>stream()</code> and <code>streamSSE()</code> retry on failure, but they do it slightly differently based on what failed.</p>
<p><code>stream()</code> uses a simple linear backoff tied to the retry count:</p>
<pre><code class="language-typescript">await new Promise((resolve) =&gt; setTimeout(resolve, 1000 * retryCount));
</code></pre>
<p><code>streamSSE()</code> respects the server-specified <code>retry</code> field from the SSE spec when one is provided, falling back to a default otherwise:</p>
<pre><code class="language-typescript">let retryInterval = 1000;
// ... updated from the "retry" field if the server sends one
await new Promise((r) =&gt; setTimeout(r, retryInterval));
</code></pre>
<p>Letting the server influence the retry interval matters in practice. A server under load can tell clients to back off longer, which is exactly the kind of cooperative behavior the SSE spec was designed to support.</p>
<p>In both functions, an aborted <code>AbortSignal</code> always short-circuits the retry loop. Treating a deliberate cancellation as a retryable failure is a common bug, and the fix is just checking <code>signal?.aborted</code> before deciding to retry.</p>
<h2 id="heading-how-to-use-this-with-react">How to Use This with React</h2>
<p>Because both functions are async generators, integrating with React state is a matter of looping and calling <code>setState</code> per chunk:</p>
<pre><code class="language-typescript">function ChatComponent() {
  const [messages, setMessages] = useState("");

  useEffect(() =&gt; {
    const controller = new AbortController();

    (async () =&gt; {
      try {
        for await (const chunk of stream("/api/chat", { signal: controller.signal })) {
          setMessages((prev) =&gt; prev + chunk);
        }
      } catch (err: any) {
        if (err.name !== "AbortError") console.error(err);
      }
    })();

    return () =&gt; controller.abort();
  }, []);

  return &lt;div&gt;{messages}&lt;/div&gt;;
}
</code></pre>
<p>The cleanup function calling <code>controller.abort()</code> is doing real work here. Without it, navigating away from the component while a stream is still active leaves the fetch running in the background, updating state on an unmounted component.</p>
<h2 id="heading-how-to-use-this-with-react-server-components">How to Use This with React Server Components</h2>
<p>Because the generator yields values one at a time, you can also drive a recursive Suspense boundary directly from the async iterator, streaming HTML to the client as each chunk arrives:</p>
<pre><code class="language-typescript">async function StreamViewer({ iterator }: { iterator: AsyncIterator&lt;string&gt; }) {
  const { value, done } = await iterator.next();
  if (done) return null;

  return (
    &lt;span&gt;
      {value}
      &lt;Suspense&gt;
        &lt;StreamViewer iterator={iterator} /&gt;
      &lt;/Suspense&gt;
    &lt;/span&gt;
  );
}

export default function Page() {
  const dataStream = stream("https://api.example.com/stream");
  const iterator = dataStream[Symbol.asyncIterator]();

  return (
    &lt;Suspense fallback="Loading..."&gt;
      &lt;StreamViewer iterator={iterator} /&gt;
    &lt;/Suspense&gt;
  );
}
</code></pre>
<p>Each recursive call awaits the next chunk and renders a nested <code>Suspense</code> boundary for the rest. React streams each piece of HTML to the client as it resolves, rather than waiting for the entire response.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A reliable streaming client needs to handle more than the success path. Connections drop, chunks arrive split across line boundaries, and cancellation needs to be distinguished from failure.</p>
<p>Ore's approach to this is built from a small set of ideas:</p>
<ul>
<li><p>Expose streams as async generators so consumers can use <code>for await...of</code></p>
</li>
<li><p>Parse SSE by hand, field by field, respecting the spec's blank-line event boundaries and buffering incomplete lines across chunks</p>
</li>
<li><p>Track <code>Last-Event-ID</code> so reconnection can resume rather than restart</p>
</li>
<li><p>Treat retries and cancellation as separate concerns</p>
</li>
<li><p>Stay framework-agnostic at the core, with thin integration points for React and React Server Components</p>
</li>
</ul>
<p>That combination is what separates a streaming client that works in a demo from one that holds up against real network conditions. You can explore the full source code at <a href="https://github.com/glamboyosa/ore">github.com/glamboyosa/ore</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Animated Badge Component with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ Badges are everywhere in modern web apps. You see them on notification counters, status labels, and feature tags. Most of them are static, though. They sit there doing nothing, blending into the page. ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-animated-badge-component-with-shadcn-ui/</link>
                <guid isPermaLink="false">6a3c0d40b101451dd3ba52e8</guid>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Wed, 24 Jun 2026 17:00:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/90ffff22-4ea2-47c2-8b8c-011e8e566301.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Badges are everywhere in modern web apps. You see them on notification counters, status labels, and feature tags.</p>
<p>Most of them are static, though. They sit there doing nothing, blending into the page. But a well-animated badge can tell the user something happened without them having to read a single word.</p>
<p>In this tutorial, you'll build an animated “success” badge using shadcn/ui, Tailwind CSS, and Framer Motion. The badge will have a glowing top light, an animated check icon that bounces into view, and letters that drop in one at a time with a stagger effect.</p>
<p>The component comes from the <a href="https://shadcnspace.com/components/badge"><strong>Shadcn Space badge collection</strong></a> and uses the Base UI primitive version of Badge. You'll install it with a single CLI command, then walk through every piece of code.</p>
<p>By the end, you'll build an animated "Success" badge by:</p>
<ol>
<li><p>Installing the <code>badge-07</code> component from Shadcn Space using the Shadcn CLI</p>
</li>
<li><p>Using <code>motion.create()</code> to wrap the shadcn/ui <code>Badge</code> into an animatable component</p>
</li>
<li><p>Adding layered radial-gradient glow effects as absolutely positioned spans</p>
</li>
<li><p>Animating the check icon with a scale and rotate entrance</p>
</li>
<li><p>Animating each letter of the label individually using staggered <code>variants</code></p>
</li>
</ol>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-how-to-install-the-component">How to Install the Component</a></p>
</li>
<li><p><a href="#heading-component-structure">Component Structure</a></p>
</li>
<li><p><a href="#heading-step-1-set-up-the-imports">Step 1: Set Up the Imports</a></p>
</li>
<li><p><a href="#heading-step-2-define-letter-animation-variants">Step 2: Define Letter Animation Variants</a></p>
</li>
<li><p><a href="#heading-step-3-wrap-the-badge-with-motion">Step 3: Wrap the Badge with Motion</a></p>
</li>
<li><p><a href="#heading-step-4-build-the-glow-layers">Step 4: Build the Glow Layers</a></p>
</li>
<li><p><a href="#heading-step-5-animate-the-icon">Step 5: Animate the Icon</a></p>
</li>
<li><p><a href="#heading-step-6-animate-each-letter">Step 6: Animate Each Letter</a></p>
</li>
<li><p><a href="#heading-how-to-use-it-in-your-app">How to Use It in Your App</a></p>
</li>
<li><p><a href="#heading-how-to-customize-the-component">How to Customize the Component</a></p>
</li>
<li><p><a href="#heading-live-preview">Live Preview</a></p>
</li>
<li><p><a href="#heading-key-concepts-recap">Key Concepts Recap</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>You'll need:</p>
<ul>
<li><p>A Next.js project with shadcn/ui initialized</p>
</li>
<li><p>Tailwind CSS set up</p>
</li>
<li><p><code>motion</code> installed: <code>npm install motion</code></p>
</li>
<li><p><code>lucide-react</code> installed: <code>npm install lucide-react</code></p>
</li>
<li><p>Basic TypeScript and React knowledge</p>
</li>
</ul>
<h2 id="heading-what-youll-build"><strong>What You'll Build</strong></h2>
<p>In this tutorial, we'll build a self-contained animated badge with three moving parts:</p>
<pre><code class="language-plaintext">├── MotionBadge (outline, rounded-full, teal border)
│   ├── Glow layers  → 3 radial gradient spans above the top border
│   ├── CheckCircle  → scale + rotate entrance, easeOutBack
│   └── Letter spans → staggered drop-in, easeOutCubic
</code></pre>
<p>After installation, the component file lands here:</p>
<pre><code class="language-plaintext">components/
└── shadcn-space/
    └── badge/
        └── badge-07.tsx
</code></pre>
<h2 id="heading-how-to-install-the-component"><strong>How to Install the Component</strong></h2>
<p><a href="https://shadcnspace.com/"><strong>Shadcn UI</strong></a> provides a registry of production-ready components. You pull them into your project with the Shadcn CLI, just like you'd add any standard shadcn/ui component.</p>
<p>Before running any command, check the <a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli"><strong>Getting Started guide</strong></a> or the <a href="https://shadcnspace.com/cli"><strong>CLI page</strong></a> for setup details.</p>
<p>You can also follow along with this video walkthrough:</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/n6dvjVxy02U" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>Run the command for your package manager:</p>
<p><strong>pnpm</strong></p>
<pre><code class="language-javascript">pnpm dlx shadcn@latest add @shadcn-space/badge-07
</code></pre>
<p><strong>npm</strong></p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/badge-07
</code></pre>
<p><strong>Yarn</strong></p>
<pre><code class="language-javascript">yarn dlx shadcn@latest add @shadcn-space/badge-07
</code></pre>
<p><strong>Bun</strong></p>
<pre><code class="language-javascript">bunx --bun shadcn@latest add @shadcn-space/badge-07
</code></pre>
<p><strong>Note:</strong> <code>badge-07</code> uses the <strong>Base UI</strong> primitive version of Badge. Both Radix and Base UI versions are available in the registry. This tutorial covers the Base UI version.</p>
<h2 id="heading-component-structure"><strong>Component Structure</strong></h2>
<p>Here's the complete component. Read through it once, then each step below breaks down a specific part.</p>
<pre><code class="language-javascript">'use client'
import { motion, type Variants } from "motion/react";
import { CheckCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";

const LETTER_VARIANTS: Variants = {
  hidden: { y: -14, opacity: 0 },
  visible: (i: number) =&gt; ({
    y: 0,
    opacity: 1,
    transition: {
      delay: i * 0.038,
      duration: 0.35,
      ease: [0.215, 0.61, 0.355, 1],
    },
  }),
};

const MotionBadge = motion.create(Badge);

const SuccessBadgeDemo = () =&gt; {
  const label = "Success";

  return (
    &lt;MotionBadge
      variant="outline"
      className={cn(
        "relative h-auto cursor-default overflow-visible rounded-full",
        "gap-2 px-3 py-2",
        "bg-background backdrop-blur-md",
        "text-foreground text-sm font-medium leading-none",
        "border-teal-400/25",
      )}
    &gt;
      {/* Top glow */}
      &lt;motion.span
        aria-hidden
        animate={{ opacity: 0.55 }}
        transition={{ duration: 0.45 }}
        className="pointer-events-none absolute -top-2 left-[10%] right-[10%] h-4 blur bg-[radial-gradient(ellipse_80%_100%_at_50%_100%,rgba(45,212,191,0.95)_0%,transparent_70%)]"
      /&gt;
      &lt;motion.span
        aria-hidden
        animate={{ opacity: 0.75 }}
        transition={{ duration: 0.45 }}
        className="pointer-events-none absolute -top-1 left-[22%] right-[22%] h-2 blur-sm bg-[radial-gradient(ellipse_70%_100%_at_50%_100%,rgba(45,212,191,0.85)_0%,transparent_70%)]"
      /&gt;
      &lt;motion.span
        aria-hidden
        animate={{ opacity: 0.9 }}
        transition={{ duration: 0.45 }}
        className="pointer-events-none absolute top-0 left-[28%] right-[28%] h-px bg-[radial-gradient(ellipse_40%_50%_at_50%_50%,rgba(45,212,191,0.95)_0%,transparent_100%)]"
      /&gt;

      {/* Icon */}
      &lt;motion.span
        initial={{ scale: 0.35, opacity: 0, rotate: -25 }}
        animate={{ scale: 1, opacity: 1, rotate: 0 }}
        transition={{ duration: 0.32, ease: [0.175, 0.885, 0.32, 1.275] }}
        className="flex h-4 w-4 shrink-0 items-center justify-center"
      &gt;
        &lt;CheckCircle size={16} strokeWidth={2} className="text-teal-400" /&gt;
      &lt;/motion.span&gt;

      {/* Animated label */}
      &lt;span className="inline-flex overflow-hidden leading-none"&gt;
        {label.split("").map((char, i) =&gt; (
          &lt;motion.span
            key={i}
            custom={i}
            variants={LETTER_VARIANTS}
            initial="hidden"
            animate="visible"
            className="inline-block whitespace-pre"
          &gt;
            {char}
          &lt;/motion.span&gt;
        ))}
      &lt;/span&gt;
    &lt;/MotionBadge&gt;
  );
};

export default SuccessBadgeDemo;
</code></pre>
<p>Now let's break it down piece by piece.</p>
<h2 id="heading-step-1-set-up-the-imports"><strong>Step 1: Set Up the Imports</strong></h2>
<pre><code class="language-javascript">'use client'
import { motion, type Variants } from "motion/react";
import { CheckCircle } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
</code></pre>
<p><code>'use client'</code> marks this as a Client Component in Next.js App Router. Motion animations run in the browser, not on the server, so this directive is required.</p>
<p><code>motion/react</code> is the import path for Motion v11 and above. If your project uses an older version, the import is <code>framer-motion</code>. The <code>Variants</code> type is a TypeScript helper for typing named animation state objects.</p>
<p><code>cn()</code> is the class name utility that ships with every shadcn/ui project. It merges Tailwind classes and handles conditional logic cleanly.</p>
<h2 id="heading-step-2-define-letter-animation-variants"><strong>Step 2: Define Letter Animation Variants</strong></h2>
<pre><code class="language-javascript">const LETTER_VARIANTS: Variants = {
  hidden: { y: -14, opacity: 0 },
  visible: (i: number) =&gt; ({
    y: 0,
    opacity: 1,
    transition: {
      delay: i * 0.038,
      duration: 0.35,
      ease: [0.215, 0.61, 0.355, 1],
    },
  }),
};
</code></pre>
<p>Each letter starts 14px above its final position and is fully transparent. When the component mounts, it moves to <code>y: 0</code> at full opacity.</p>
<p>The <code>delay: i * 0.038</code> formula is the stagger. Letter 0 has no delay, letter 1 waits 38ms, letter 2 waits 76ms, and so on. This makes the letters appear to cascade in from left to right.</p>
<p>The <code>ease</code> value <code>[0.215, 0.61, 0.355, 1]</code> is <code>easeOutCubic</code>. It starts fast and decelerates at the end, giving each letter a natural landing rather than a hard stop.</p>
<p>The <code>visible</code> function accepts a <code>custom</code> value. When you pass <code>custom={i}</code> on the <code>motion.span</code>, Motion calls this function with that index. Each letter calculates its own delay independently.</p>
<p><strong>Accessibility tip:</strong> To respect users with reduced motion preferences, import <code>useReducedMotion</code> from <code>motion/react</code> and skip the stagger when it returns <code>true</code>.</p>
<h2 id="heading-step-3-wrap-the-badge-with-motion"><strong>Step 3: Wrap the Badge with Motion</strong></h2>
<pre><code class="language-javascript">const MotionBadge = motion.create(Badge);
</code></pre>
<p>The <code>Badge</code> Component from shadcn/ui is a standard React component. You can't apply Motion props like <code>animate</code> or <code>initial</code> to it directly.</p>
<p><code>motion.create()</code> wraps any React component and returns a new version that accepts all Motion animation props. The result, <code>MotionBadge</code>, behaves exactly like <code>Badge</code> But it's now fully animatable.</p>
<p>Use this pattern any time you want to animate a custom or third-party library component with Motion.</p>
<h2 id="heading-step-4-build-the-glow-layers"><strong>Step 4: Build the Glow Layers</strong></h2>
<pre><code class="language-javascript">&lt;motion.span
  aria-hidden
  animate={{ opacity: 0.55 }}
  transition={{ duration: 0.45 }}
  className="pointer-events-none absolute -top-2 left-[10%] right-[10%] h-4 blur bg-[radial-gradient(ellipse_80%_100%_at_50%_100%,rgba(45,212,191,0.95)_0%,transparent_70%)]"
/&gt;
&lt;motion.span
  aria-hidden
  animate={{ opacity: 0.75 }}
  transition={{ duration: 0.45 }}
  className="pointer-events-none absolute -top-1 left-[22%] right-[22%] h-2 blur-sm bg-[radial-gradient(ellipse_70%_100%_at_50%_100%,rgba(45,212,191,0.85)_0%,transparent_70%)]"
/&gt;
&lt;motion.span
  aria-hidden
  animate={{ opacity: 0.9 }}
  transition={{ duration: 0.45 }}
  className="pointer-events-none absolute top-0 left-[28%] right-[28%] h-px bg-[radial-gradient(ellipse_40%_50%_at_50%_50%,rgba(45,212,191,0.95)_0%,transparent_100%)]"
/&gt;
</code></pre>
<p>Three spans stack on top of each other above the badge border. Each is narrower and more opaque than the one behind it:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Position</th>
<th>Width</th>
<th>Blur</th>
<th>Final Opacity</th>
</tr>
</thead>
<tbody><tr>
<td>Outer</td>
<td><code>-top-2</code></td>
<td>80%</td>
<td><code>blur</code></td>
<td>0.55</td>
</tr>
<tr>
<td>Middle</td>
<td><code>-top-1</code></td>
<td>56%</td>
<td><code>blur-sm</code></td>
<td>0.75</td>
</tr>
<tr>
<td>Inner line</td>
<td><code>top-0</code></td>
<td>44%</td>
<td>none</td>
<td>0.90</td>
</tr>
</tbody></table>
<p>The innermost layer is only 1px tall (<code>h-px</code>) with no blur. This gives the glow a crisp, bright edge right at the badge border. The two outer layers create the soft falloff around it.</p>
<p>All three carry <code>aria-hidden</code> because they're purely decorative. Screen readers skip them. The <code>overflow-visible</code> class on <code>MotionBadge</code> is what allows these spans to render outside the component's boundary without clipping.</p>
<h2 id="heading-step-5-animate-the-icon"><strong>Step 5: Animate the Icon</strong></h2>
<pre><code class="language-javascript">&lt;motion.span
  initial={{ scale: 0.35, opacity: 0, rotate: -25 }}
  animate={{ scale: 1, opacity: 1, rotate: 0 }}
  transition={{ duration: 0.32, ease: [0.175, 0.885, 0.32, 1.275] }}
  className="flex h-4 w-4 shrink-0 items-center justify-center"
&gt;
  &lt;CheckCircle size={16} strokeWidth={2} className="text-teal-400" /&gt;
&lt;/motion.span&gt;
</code></pre>
<p>The icon starts at 35% scale, invisible, and rotated 25 degrees counter-clockwise. It animates to full size and zero rotation on mount.</p>
<p>The <code>ease</code> value <code>[0.175, 0.885, 0.32, 1.275]</code> is <code>easeOutBack</code>. Unlike <code>easeOutCubic</code>, this curve overshoots its target slightly before snapping back. The icon appears to spring into place. It is a subtle effect, but it makes the icon feel physical.</p>
<p><code>shrink-0</code> on the wrapper prevents the icon from compressing inside the flex container.</p>
<h2 id="heading-step-6-animate-each-letter"><strong>Step 6: Animate Each Letter</strong></h2>
<pre><code class="language-javascript">&lt;span className="inline-flex overflow-hidden leading-none"&gt;
  {label.split("").map((char, i) =&gt; (
    &lt;motion.span
      key={i}
      custom={i}
      variants={LETTER_VARIANTS}
      initial="hidden"
      animate="visible"
      className="inline-block whitespace-pre"
    &gt;
      {char}
    &lt;/motion.span&gt;
  ))}
&lt;/span&gt;
</code></pre>
<p><code>label.split("")</code> turns <code>"Success"</code> into <code>["S", "u", "c", "c", "e", "s", "s"]</code>. Each character gets its own <code>motion.span</code>.</p>
<p><code>variants={LETTER_VARIANTS}</code> connects each span to the animation states from Step 2. <code>custom={i}</code> passes the character's index into the <code>visible</code> resolver so each letter knows its own delay.</p>
<p>Two Tailwind classes matter here:</p>
<ul>
<li><p><code>overflow-hidden</code> on the wrapper clips, each letter as it slides in from above. Without it, letters would be visible outside the badge before they land.</p>
</li>
<li><p><code>inline-block</code> on each <code>motion.span</code> is required for <code>translateY</code> to work. CSS transforms do not apply to inline elements by default.</p>
</li>
</ul>
<h2 id="heading-how-to-use-it-in-your-app"><strong>How to Use It in Your App</strong></h2>
<p>Import and render <code>SuccessBadgeDemo</code> anywhere in your project:</p>
<pre><code class="language-javascript">// app/page.tsx
import SuccessBadgeDemo from "@/components/shadcn-space/badge/badge-07";

export default function Page() {
  return (
    &lt;div className="flex items-center justify-center min-h-screen"&gt;
      &lt;SuccessBadgeDemo /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The component is self-contained. It carries its own animation state, theme tokens, and glow layers. No props are required.</p>
<h2 id="heading-how-to-customize-the-component"><strong>How to Customize the Component</strong></h2>
<p>You can change the label by replacing <code>"Success"</code> it with any string. The letter animation applies automatically since it splits whatever string you pass.</p>
<p>To build a complete blue "Verified" variant, you just need to change three things: the border color class, the glow gradient color values, and the icon. Here's the full updated component:</p>
<pre><code class="language-javascript">'use client'
import { motion, type Variants } from "motion/react";
import { ShieldCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";

const LETTER_VARIANTS: Variants = {
  hidden: { y: -14, opacity: 0 },
  visible: (i: number) =&gt; ({
    y: 0,
    opacity: 1,
    transition: {
      delay: i * 0.038,
      duration: 0.35,
      ease: [0.215, 0.61, 0.355, 1],
    },
  }),
};

const MotionBadge = motion.create(Badge);

const VerifiedBadgeDemo = () =&gt; {
  const label = "Verified";

  return (
    &lt;MotionBadge
      variant="outline"
      className={cn(
        "relative h-auto cursor-default overflow-visible rounded-full",
        "gap-2 px-3 py-2",
        "bg-background backdrop-blur-md",
        "text-foreground text-sm font-medium leading-none",
        "border-blue-400/25",
      )}
    &gt;
      &lt;motion.span aria-hidden animate={{ opacity: 0.55 }} transition={{ duration: 0.45 }}
        className="pointer-events-none absolute -top-2 left-[10%] right-[10%] h-4 blur bg-[radial-gradient(ellipse_80%_100%_at_50%_100%,rgba(96,165,250,0.95)_0%,transparent_70%)]"
      /&gt;
      &lt;motion.span aria-hidden animate={{ opacity: 0.75 }} transition={{ duration: 0.45 }}
        className="pointer-events-none absolute -top-1 left-[22%] right-[22%] h-2 blur-sm bg-[radial-gradient(ellipse_70%_100%_at_50%_100%,rgba(96,165,250,0.85)_0%,transparent_70%)]"
      /&gt;
      &lt;motion.span aria-hidden animate={{ opacity: 0.9 }} transition={{ duration: 0.45 }}
        className="pointer-events-none absolute top-0 left-[28%] right-[28%] h-px bg-[radial-gradient(ellipse_40%_50%_at_50%_50%,rgba(96,165,250,0.95)_0%,transparent_100%)]"
      /&gt;

      &lt;motion.span
        initial={{ scale: 0.35, opacity: 0, rotate: -25 }}
        animate={{ scale: 1, opacity: 1, rotate: 0 }}
        transition={{ duration: 0.32, ease: [0.175, 0.885, 0.32, 1.275] }}
        className="flex h-4 w-4 shrink-0 items-center justify-center"
      &gt;
        &lt;ShieldCheck size={16} strokeWidth={2} className="text-blue-400" /&gt;
      &lt;/motion.span&gt;

      &lt;span className="inline-flex overflow-hidden leading-none"&gt;
        {label.split("").map((char, i) =&gt; (
          &lt;motion.span key={i} custom={i} variants={LETTER_VARIANTS}
            initial="hidden" animate="visible" className="inline-block whitespace-pre"
          &gt;
            {char}
          &lt;/motion.span&gt;
        ))}
      &lt;/span&gt;
    &lt;/MotionBadge&gt;
  );
};

export default VerifiedBadgeDemo;
</code></pre>
<p>The only changes from the original: <code>border-blue-400/25</code> on the badge, <code>rgba(96, 165, 250, ...)</code> in the glow gradients (<code>blue-400</code> in Tailwind), <code>ShieldCheck</code> for the icon, and <code>text-blue-400</code> on the icon class.</p>
<p>To adjust stagger speed, just change the delay multiplier in <code>LETTER_VARIANTS</code>:</p>
<pre><code class="language-javascript">delay: i * 0.06, // slower stagger
delay: i * 0.02, // faster stagger
</code></pre>
<p>You can also explore the <a href="https://shadcnspace.com/blocks"><strong>Shadcn Blocks</strong></a> collection to see how animated badges fit into full dashboard and card layouts.</p>
<hr>
<h2 id="heading-live-preview"><strong>Live Preview</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/08db3820-9f72-4ddb-a507-e33cdcda5fb8.gif" alt="08db3820-9f72-4ddb-a507-e33cdcda5fb8" style="display:block;margin:0 auto" width="1152" height="648" loading="lazy">

<h2 id="heading-key-concepts-recap"><strong>Key Concepts Recap</strong></h2>
<table>
<thead>
<tr>
<th>Concept</th>
<th>What It Does</th>
</tr>
</thead>
<tbody><tr>
<td><code>motion.create(Component)</code></td>
<td>Wraps any React component to accept Motion animation props</td>
</tr>
<tr>
<td><code>Variants</code></td>
<td>Named animation states (<code>hidden</code>, <code>visible</code>) defined outside JSX for reuse</td>
</tr>
<tr>
<td><code>custom={i}</code> + variant function</td>
<td>Passes a per-element value into the variant resolver for dynamic transitions</td>
</tr>
<tr>
<td><code>delay: i * 0.038</code></td>
<td>Stagger formula: each element's delay grows by its index</td>
</tr>
<tr>
<td><code>easeOutCubic</code> <code>[0.215, 0.61, 0.355, 1]</code></td>
<td>Fast start, smooth deceleration. Letter drop-in.</td>
</tr>
<tr>
<td><code>easeOutBack</code> <code>[0.175, 0.885, 0.32, 1.275]</code></td>
<td>Overshoots slightly, snaps back. Icon pop.</td>
</tr>
<tr>
<td>Three stacked radial gradients</td>
<td>Wide + soft outer glow, narrow + sharp inner line</td>
</tr>
<tr>
<td><code>overflow-visible</code> on the badge</td>
<td>Allows glow spans to extend outside the component's own bounds</td>
</tr>
</tbody></table>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>In this tutorial, you built a complete animated badge from scratch with a layered glow, bouncing icon, and staggered letter animation. Every part of it uses your existing Shadcn theme tokens, so it drops into any project without extra configuration.</p>
<p>You can browse more <a href="https://shadcnspace.com/components"><strong>Shadcn Components</strong></a> on Shadcn Space to apply the same animation patterns to other UI elements. If you work with external services and tooling in your stack, the <a href="https://shadcnspace.com/mcp"><strong>Shadcn MCP</strong></a> integration is worth looking at as a next step.</p>
<h2 id="heading-resources"><strong>Resources</strong></h2>
<ul>
<li><p><a href="https://shadcnspace.com/components/badge"><strong>Shadcn Space Badge Components</strong></a>: with all badge variants, including Pending, Failed, and more</p>
</li>
<li><p><a href="https://shadcnspace.com/docs/getting-started/how-to-use-shadcn-cli"><strong>Shadcn Space Getting Started Guide</strong></a>: how to use the Shadcn CLI with third-party registries</p>
</li>
<li><p><a href="https://motion.dev/"><strong>Motion Docs</strong></a>: official documentation for <code>motion/react</code></p>
</li>
<li><p><a href="https://lucide.dev/"><strong>Lucide React</strong></a>: icon library used in this tutorial</p>
</li>
<li><p><a href="https://ui.shadcn.com/docs"><strong>Shadcn/ui Documentation</strong></a></p>
</li>
<li><p><a href="https://youtu.be/n6dvjVxy02U?si=EXfClzSyI8D97VaI"><strong>YouTube: Shadcn Space CLI Walkthrough</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Scalable Design System in a Monorepo ]]>
                </title>
                <description>
                    <![CDATA[ When you hear "Scalable Design System with a Monorepo Ecosystem" it might sound like a bunch of jargon glued together. Let's simplify: Design system: the building blocks of your product (buttons, inp ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-scalable-design-system-in-a-monorepo/</link>
                <guid isPermaLink="false">6a397b0b12901591d0138d81</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Design Systems ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monorepo ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Frontend Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vineeth Pawar ]]>
                </dc:creator>
                <pubDate>Mon, 22 Jun 2026 18:12:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e1f42e08-4158-4ecb-8d71-5371cfe86707.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When you hear "Scalable Design System with a Monorepo Ecosystem" it might sound like a bunch of jargon glued together. Let's simplify:</p>
<ul>
<li><p><strong>Design system</strong>: the building blocks of your product (buttons, inputs, styles, tokens, patterns).</p>
</li>
<li><p><strong>Monorepo</strong>: one big repo with multiple packages living together, sharing tooling and workflows.</p>
</li>
</ul>
<p>Now here's the magic: when you combine them, you get modularity, consistency, and a faster development cycle. Basically the dream setup for teams working across web, mobile, and beyond.</p>
<p>In this article, you'll learn how to build a modular, scalable design system using React and Turborepo – the same approach used by Microsoft, IBM, and Shopify.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-whos-already-doing-this">Who's Already Doing This?</a></p>
</li>
<li><p><a href="#heading-why-it-works">Why it Works</a></p>
</li>
<li><p><a href="#heading-think-of-it-like-a-ladder">Think of it Like a Ladder</a></p>
</li>
<li><p><a href="#heading-the-same-design-system-everywhere">The Same Design System, Everywhere</a></p>
</li>
<li><p><a href="#heading-should-you-go-monorepo">Should You Go Monorepo?</a></p>
</li>
<li><p><a href="#heading-when-a-monorepo-is-not-the-right-fit">When a Monorepo Is Not the Right Fit</a></p>
</li>
<li><p><a href="#heading-lets-build-our-design-system">Let's Build Our Design System</a></p>
<ul>
<li><p><a href="#heading-create-your-turborepo-project">Create Your Turborepo Project</a></p>
</li>
<li><p><a href="#heading-design-your-package-structure">Design Your Package Structure</a></p>
</li>
<li><p><a href="#heading-build-your-design-tokens-package">Build Your Design Tokens Package</a></p>
</li>
<li><p><a href="#heading-create-primitive-components">Create Primitive Components</a></p>
</li>
<li><p><a href="#heading-configure-the-turborepo-pipeline">Configure the Turborepo Pipeline</a></p>
</li>
<li><p><a href="#heading-build-the-yourds-packages">Build the @yourds Packages</a></p>
</li>
<li><p><a href="#heading-use-your-design-system-in-an-app">Use Your Design System in an App</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you follow along, you'll want to have a few things in place:</p>
<ul>
<li><p><strong>Working knowledge of React and TypeScript:</strong> You should be comfortable creating components and reading basic type annotations.</p>
</li>
<li><p><strong>Familiarity with the command line:</strong> You'll run <code>npx</code>, <code>npm</code>, and similar commands throughout.</p>
</li>
<li><p><strong>Node.js installed (v18 or later)</strong>: Verify with <code>node -v</code>. If you don't have it, install it from <a href="https://nodejs.org">nodejs.org</a>.</p>
</li>
<li><p><strong>A package manager:</strong> This guide uses <code>npm</code>, but <code>pnpm</code> or <code>yarn</code> will work with minor command tweaks.</p>
</li>
<li><p><strong>A code editor</strong> of your choice (VS Code is a popular fit for TypeScript work).</p>
</li>
</ul>
<p>You don't need any prior experience with monorepos or Turborepo. We'll set everything up from scratch.</p>
<h2 id="heading-whos-already-doing-this">Who's Already Doing This?</h2>
<p>Turns out, some of the biggest design systems you've heard of run inside monorepos:</p>
<ol>
<li><p><a href="https://github.com/microsoft/fluentui/wiki/Fluent-UI-React-Repo-Structure/d7060a0782b639b657cf7a9c0826bff757ad78b5">Microsoft Fluent UI</a>: lives in a multi-package monorepo that ships React components, Web Components, and even design tokens.</p>
</li>
<li><p><a href="https://github.com/carbon-design-system/ibm-products">IBM Carbon</a>: multiple packages like <code>@carbon/ibm-products</code> come straight out of their Carbon monorepo.</p>
</li>
<li><p><a href="https://github.com/Shopify/polaris-react">Shopify Polaris</a>: openly describes itself as a monorepo, packaging React components, docs, and even a VS Code extension.</p>
</li>
<li><p><a href="https://github.com/atlassian/pragmatic-drag-and-drop">Atlassian Atlaskit</a>: their public <code>@atlaskit/*</code> packages are published from a large internal monorepo.</p>
</li>
<li><p><a href="https://github.com/mui/mui-public/tree/master">MUI</a> (Material UI): maintained as a mono-repository to coordinate React components, tooling, and docs.</p>
</li>
<li><p><a href="https://github.com/elastic/eui">Elastic EUI</a>: developed and released from a single repo, with discussions about monorepo publishing flows.</p>
</li>
</ol>
<h2 id="heading-why-it-works">Why it Works</h2>
<p>When you put all the pieces of your design system in one repository, you get a few specific advantages that are hard to replicate in a split-repo setup. Each of these reinforces the others, which is why teams that adopt this pattern rarely go back.</p>
<p>Here's what makes it work:</p>
<ul>
<li><p><strong>Consistency</strong>: tokens, styles, and primitives are defined once and flow everywhere.</p>
</li>
<li><p><strong>Faster iteration</strong>: fix a bug in Button and the updates cascade to mobile, desktop, and docs instantly.</p>
</li>
<li><p><strong>Shared tooling</strong>: linting, tests, CI pipelines, and release workflows are configured once, and then applied to all packages.</p>
</li>
<li><p><strong>Versioning control</strong>: with tools like Changesets or Lerna, you can release packages independently but keep them aligned.</p>
</li>
<li><p><strong>Cross-platform flexibility</strong>: the same building blocks can power React web apps, React Native, Electron apps, SDKs, and documentation sites.</p>
</li>
</ul>
<h2 id="heading-think-of-it-like-a-ladder">Think of it Like a Ladder 🪜</h2>
<p>The cleanest way to picture a monorepo design system is as a series of stacked layers. Each layer builds on the one beneath it, and each layer has a clear job.</p>
<p>New contributors find their way around faster because the relationships between packages are predictable: tokens flow up into primitives, primitives compose into layouts, and layouts assemble into screens.</p>
<p>The diagram below shows this stack visually:</p>
<img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhhcenvi46zcjfwrl1odj.png" alt="Layered architecture of a monorepo design system: design tokens at the base, then plugins (utility helpers), then layouts, then screens, then navigators at the top, with the app shell consuming a single package that pulls all layers together" style="display:block;margin:0 auto" width="800" height="500" loading="lazy">

<p>At the base, you've got <code>primitives</code> (tokens, styles).</p>
<p>Above that: <code>plugins</code> (utility helpers).</p>
<p>Then come <code>layouts</code>, built from plugins + primitives.</p>
<p>Then <code>screens</code>, built from layouts.</p>
<p>Finally, <code>navigators</code> tie screens together.</p>
<p>At the very top: your app imports just one package, and boom! The UI is environment-agnostic.</p>
<h2 id="heading-the-same-design-system-everywhere">The Same Design System, Everywhere</h2>
<p>The real payoff of this ladder is that you climb it once, then reuse the whole thing across every platform you ship to.</p>
<p>A button defined in your <code>primitives</code> package can render in a web app, a React Native mobile app, an Electron desktop app, or a documentation site without you rewriting it for each environment.</p>
<p>The diagram below shows the same design system flowing into three different app types, with each environment importing the same package and getting consistent styling, behaviour, and accessibility out of the box:</p>
<img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqsa4y8m103unz7hefr3u.png" alt="The same design system feeding three different apps from a single import: a web application on a browser, a desktop application in an Electron-style window, and a mobile application on a phone screen. Each app pulls from the shared primitives and tokens packages, ensuring buttons, typography, and spacing look and behave the same everywhere" style="display:block;margin:0 auto" width="800" height="500" loading="lazy">

<p>Whether it's web, desktop, or mobile, the design system climbs that same ladder.</p>
<h2 id="heading-should-you-go-monorepo">Should You Go Monorepo?</h2>
<p>Not every team needs one. But if you're building a design system that's meant to serve multiple apps, stay consistent across platforms, and support lots of contributors, then a monorepo becomes less of a buzzword and more of a sanity-saver.</p>
<h2 id="heading-when-a-monorepo-is-not-the-right-fit">When a Monorepo Is Not the Right Fit</h2>
<p>A quick clarification first, because monorepos sometimes get tangled up with another debate. The "monorepo vs polyrepo" question is <strong>not</strong> the same as the "monolith vs microservices" question. You can absolutely run microservices out of a monorepo (Google and Facebook do this at massive scale).</p>
<p>The two choices live on different axes: monorepo vs polyrepo is about <em>where the code lives</em>, while monolith vs microservices is about <em>how the runtime is shaped</em>.</p>
<p>With that out of the way, here are a few signs a monorepo may not be the best fit for your situation:</p>
<ul>
<li><p><strong>You're a small team shipping a single product.</strong> The tooling overhead of a monorepo (workspace config, build pipelines, package boundaries) may slow you down more than it helps. A single React app with no shared libraries probably doesn't need this layer.</p>
</li>
<li><p><strong>Your packages have wildly different release cadences and stakeholders.</strong> If two parts of your codebase are owned by teams that need very different deploy pipelines, governance, or security postures, separate repos can reduce friction.</p>
</li>
<li><p><strong>You can't invest in monorepo tooling.</strong> Tools like Turborepo, Nx, and Changesets do a lot of heavy lifting, but they have a learning curve. If your team can't dedicate time to set them up and maintain them, you may struggle.</p>
</li>
<li><p><strong>You're using languages or runtimes that don't share well.</strong> Monorepos shine when most packages live in the same toolchain. Mixing Node, Go, Rust, and Python in one repo is possible, but the build-tool story gets harder.</p>
</li>
</ul>
<p>For most teams building a serious design system, none of these are dealbreakers. But it's worth checking your situation before committing.</p>
<h2 id="heading-lets-build-our-design-system">Let's Build Our Design System</h2>
<h3 id="heading-create-your-turborepo-project">Create Your Turborepo Project</h3>
<p>Start by creating a new Turborepo project. This gives you the perfect foundation for a scalable monorepo.</p>
<pre><code class="language-plaintext"># Create a new Turborepo project
npx create-turbo@latest my-design-system

# Navigate to the project
cd my-design-system

# Install dependencies
npm install
</code></pre>
<p>Turborepo creates a workspace with <code>apps/</code> and <code>packages/</code> folders, shared tooling configuration, and optimized build pipelines.</p>
<h3 id="heading-design-your-package-structure">Design Your Package Structure</h3>
<p>Next, create a logical hierarchy for your design system packages. Think of it like a ladder, as I mentioned above: each level builds on the one below.</p>
<pre><code class="language-plaintext">my-design-system/
├── packages/
│   ├── tokens/          # Design tokens (colors, spacing, typography)
│   ├── primitives/      # Base components (Button, Input, Card)
│   ├── layouts/         # Layout components (Grid, Stack, Container)
├── apps/
│   ├── web/            # Example web app
│   └── docs/           # Documentation site
└── turbo.json          # Turborepo configuration
</code></pre>
<h4 id="heading-detailed-file-structure">Detailed file structure</h4>
<pre><code class="language-plaintext">my-design-system/
├── packages/
│   ├── tokens/
│   │   ├── src/
│   │   │   ├── colors.ts
│   │   │   ├── spacing.ts
│   │   │   ├── typography.ts
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── primitives/
│   │   ├── src/
│   │   │   ├── Button/
│   │   │   │   └── Button.tsx
│   │   │   ├── Input/
│   │   │   │   └── Input.tsx
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── layouts/
│   │   ├── src/
│   │   │   ├── Grid/
│   │   │   ├── Stack/
│   │   │   └── index.ts
│   │   └── package.json
├── apps/
│   ├── web/
│   │   ├── src/
│   │   │   ├── App.tsx
│   │   │   └── main.tsx
│   │   ├── index.html
│   │   └── package.json
│   └── docs/
│       ├── src/
│       └── package.json
├── turbo.json
├── package.json
└── README.md
</code></pre>
<h3 id="heading-build-your-design-tokens-package">Build Your Design Tokens Package</h3>
<p>Start with the foundation: <strong>design tokens</strong>. Tokens are the smallest, most reusable units of a design system: a color value, a spacing step, a font size, a border radius. Instead of hard-coding <code>padding: 16px</code> or <code>color: #3b82f6</code> everywhere, you reference a token like <code>spacing.md</code> or <code>colors.primary[500]</code>.</p>
<p>The benefits are huge:</p>
<ul>
<li><p><strong>One place to change a value:</strong> update a token once and every component that uses it updates automatically.</p>
</li>
<li><p><strong>Theming becomes trivial:</strong> want a dark mode? Just swap which tokens resolve to which values.</p>
</li>
<li><p><strong>Cross-platform consistency:</strong> the same token names work in web CSS, native styles, even Figma.</p>
</li>
</ul>
<p>Tokens are the DNA of your design system. Let's build them.</p>
<pre><code class="language-plaintext"># Create the tokens package
mkdir -p packages/tokens/src
cd packages/tokens
</code></pre>
<p>Update these in your <code>packages/tokens/package.json</code>. This file declares the package name, version, build scripts, and dev dependencies needed to compile the token source files into a publishable package:</p>
<pre><code class="language-json">{
  "name": "@yourds/tokens",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
  },
  "devDependencies": {
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  }
}
</code></pre>
<p>Update these in your <code>packages/tokens/src/colors.ts</code>. This file defines the <strong>color tokens</strong>: a named palette of color values organised by intent (primary, gray) and shade (50 is lightest, 900 is darkest). Components reference these by name rather than hardcoding hex codes:</p>
<pre><code class="language-javascript">export const colors = {
  primary: {
    50: '#f0f9ff',
    100: '#e0f2fe',
    500: '#3b82f6',
    600: '#2563eb',
    900: '#1e3a8a'
  },
  gray: {
    50: '#f9fafb',
    100: '#f3f4f6',
    500: '#6b7280',
    900: '#111827'
  }
} as const;
</code></pre>
<p>Update these in your <code>packages/tokens/src/spacing.ts</code>. This file defines the <strong>spacing scale</strong>: a set of standard size steps that components use for padding, margin, and gap values. Using a fixed scale (xs, sm, md, lg, and so on) keeps spacing consistent across the UI:</p>
<pre><code class="language-typescript">export const spacing = {
  xs: '0.25rem',    // 4px
  sm: '0.5rem',     // 8px
  md: '1rem',       // 16px
  lg: '1.5rem',     // 24px
  xl: '2rem',       // 32px
  '2xl': '3rem'     // 48px
} as const;
</code></pre>
<p>Update these in your <code>packages/tokens/src/typography.ts</code>. This file defines the <strong>typography tokens</strong>: font sizes and font weights that components use for text. Like spacing, these are named steps rather than arbitrary pixel values:</p>
<pre><code class="language-typescript">export const typography = {
  fontSizes: {
    xs: '0.75rem',
    sm: '0.875rem',
    base: '1rem',
    lg: '1.125rem',
    xl: '1.25rem',
    '2xl': '1.5rem'
  },
  fontWeights: {
    normal: 400,
    medium: 500,
    semibold: 600,
    bold: 700
  }
} as const;
</code></pre>
<p>Update these in your <code>packages/tokens/src/index.ts</code>. This file is the <strong>public entry point</strong> of the package: it re-exports everything from the three token files so consumers can do <code>import { colors, spacing, typography } from "@yourds/tokens"</code> in a single line:</p>
<pre><code class="language-typescript">export * from './colors';
export * from './spacing';
export * from './typography';
</code></pre>
<h3 id="heading-create-primitive-components">Create Primitive Components</h3>
<p>Build your base components that consume the design tokens:</p>
<pre><code class="language-plaintext"># Create the primitives package
mkdir -p packages/primitives/src
cd packages/primitives

# Install dependencies
npm install react react-dom
</code></pre>
<p>Update these in your <code>packages/primitives/package.json</code>:</p>
<pre><code class="language-json">{
  "name": "@yourds/primitives",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts --external react",
    "dev": "tsup src/index.ts --format cjs,esm --dts --external react --watch"
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.0",
    "tsup": "^8.0.0",
    "typescript": "^5.0.0"
  }
}
</code></pre>
<p>Update these in your <code>packages/primitives/src/Button/Button.tsx</code>:</p>
<pre><code class="language-typescript">import React from 'react';
import { colors, spacing } from '@yourds/tokens';

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'outline';
  size?: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
  onClick?: () =&gt; void;
  disabled?: boolean;
}

export const Button: React.FC&lt;ButtonProps&gt; = ({
  variant = 'primary',
  size = 'md',
  children,
  disabled = false,
  ...props
}) =&gt; {
  const baseStyles = {
    border: 'none',
    borderRadius: '0.5rem',
    cursor: disabled ? 'not-allowed' : 'pointer',
    fontWeight: 500,
    transition: 'all 0.2s ease',
    opacity: disabled ? 0.6 : 1
  };

  const variants = {
    primary: {
      backgroundColor: colors.primary[500],
      color: 'white',
      ':hover': { backgroundColor: colors.primary[600] }
    },
    secondary: {
      backgroundColor: colors.gray[100],
      color: colors.gray[900],
      ':hover': { backgroundColor: colors.gray[200] }
    },
    outline: {
      backgroundColor: 'transparent',
      color: colors.primary[500],
      border: `1px solid ${colors.primary[500]}`,
      ':hover': { backgroundColor: colors.primary[50] }
    }
  };

  const sizes = {
    sm: { padding: `\({spacing.xs} \){spacing.sm}`, fontSize: '0.875rem' },
    md: { padding: `\({spacing.sm} \){spacing.md}`, fontSize: '1rem' },
    lg: { padding: `\({spacing.md} \){spacing.lg}`, fontSize: '1.125rem' }
  };

  const buttonStyle = {
    ...baseStyles,
    ...variants[variant],
    ...sizes[size]
  };

  return (
    &lt;button style={buttonStyle} disabled={disabled} {...props}&gt;
      {children}
    &lt;/button&gt;
  );
};
</code></pre>
<p>Update these in your <code>packages/primitives/src/index.ts</code>:</p>
<pre><code class="language-typescript">export { Button } from './Button/Button';
export type { ButtonProps } from './Button/Button';
</code></pre>
<h3 id="heading-configure-the-turborepo-pipeline">Configure the Turborepo Pipeline</h3>
<p>Now, set up the build pipeline in <code>turbo.json</code> to ensure packages build in the correct order.</p>
<pre><code class="language-json">{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "type-check": {
      "dependsOn": ["^build"]
    }
  }
}
</code></pre>
<h3 id="heading-build-the-yourds-packages">Build the @yourds Packages</h3>
<p>With the tokens and primitives packages defined, the next step is to compile them so they can be consumed by your apps.</p>
<p>Running <code>npm install</code> at the root resolves all workspace dependencies, including the internal links between <code>@yourds/tokens</code> and <code>@yourds/primitives</code>. Then <code>npm run build</code> walks through every package and runs each one's <code>build</code> script, which Turborepo orders correctly so <code>tokens</code> compiles before <code>primitives</code> (since primitives depend on tokens). The final <code>npm install</code> step then registers the built packages so your <code>apps/web</code> app can import them by name:</p>
<pre><code class="language-plaintext"># Go to the root of the monorepo
npm install

# Compile every package in the right order
npm run build

# Register the built packages for the apps to use
npm install @yourds/tokens @yourds/primitives
</code></pre>
<p>If everything ran successfully, you should see a <code>dist/</code> folder inside both <code>packages/tokens</code> and <code>packages/primitives</code>, containing compiled JavaScript and TypeScript declaration files.</p>
<h3 id="heading-use-your-design-system-in-an-app">Use Your Design System in an App</h3>
<p>Now you can consume your design system in any React application.</p>
<p>The example below replaces the default content in your <code>apps/web/src/App.tsx</code> file with a small home page that demonstrates two things at once: importing primitives (the <code>Button</code> component) from <code>@yourds/primitives</code>, and importing tokens (<code>colors</code>, <code>spacing</code>) directly from <code>@yourds/tokens</code> to style standard HTML elements like the wrapper <code>&lt;div&gt;</code> and the <code>&lt;h1&gt;</code>.</p>
<p>The result is a fully working page that uses your design system end-to-end, with zero hardcoded colors or spacing values:</p>
<pre><code class="language-typescript">import { Button } from "@yourds/primitives";
import { colors, spacing } from "@yourds/tokens";

export default function Home() {
  return (
    &lt;div style={{ padding: spacing.lg }}&gt;
      &lt;h1 style={{ color: colors.primary[500] }}&gt;My App with Design System&lt;/h1&gt;
      &lt;Button variant="primary" size="lg"&gt;
        Get Started
      &lt;/Button&gt;
      &lt;Button variant="outline" size="md"&gt;
        Learn More
      &lt;/Button&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Once you save the file, run the app in development mode:</p>
<pre><code class="language-plaintext">npx turbo dev --filter=web
</code></pre>
<p>You should see your home page render with the <code>primary[500]</code> blue heading, padded by <code>spacing.lg</code>, and two buttons styled by your shared design system. Any change you make to a token (say, swapping the primary color) will flow into this page automatically the next time you rebuild.</p>
<h2 id="heading-wrapping-up">Wrapping up</h2>
<p>A monorepo won't magically make your design system perfect. But it does give you:</p>
<ul>
<li><p>A shared space where everything connects</p>
</li>
<li><p>The agility to publish parts independently</p>
</li>
<li><p>The clarity to scale design across teams and platforms</p>
</li>
</ul>
<p>No wonder the biggest design systems in the world are already doing it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI-Powered, Local-First Chrome Extension That Turns Your Browsing History into an Intent Map ]]>
                </title>
                <description>
                    <![CDATA[ Your browser remembers every page you've ever opened, but it has no idea why you opened any of them. You might spend three days comparing laptops across a dozen tabs, get distracted, come back a week  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-powered-local-first-chrome-extension/</link>
                <guid isPermaLink="false">6a357903529dee82e5b4624b</guid>
                
                    <category>
                        <![CDATA[ chrome extension ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ context.dev ]]>
                    </category>
                
                    <category>
                        <![CDATA[ claude ]]>
                    </category>
                
                    <category>
                        <![CDATA[ indexeddb ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Fri, 19 Jun 2026 17:14:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/26289969-a243-46ff-87aa-095d4168bf17.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your browser remembers every page you've ever opened, but it has no idea why you opened any of them.</p>
<p>You might spend three days comparing laptops across a dozen tabs, get distracted, come back a week later, and your history just shows a flat list of timestamps and titles, with no sense that those visits were one thing, a decision you started and never finished.</p>
<p>In this tutorial, you'll build <strong>openloops</strong>, an open-source, local-first Chrome extension that fixes this by scanning your browsing history and grouping it into "intent threads" – the decisions, research, and open questions you keep coming back to – then scoring each one for how alive it still is. Optionally, it also uses Claude to label those threads in plain language, suggest a concrete next step, and power a chat assistant you can ask "what should I close this week?"</p>
<p>By the end, you'll have built:</p>
<ul>
<li><p>A Manifest V3 Chrome extension with a service worker and a full-tab dashboard</p>
</li>
<li><p>A local pipeline that captures, cleans, segments, and clusters browsing history entirely in IndexedDB</p>
</li>
<li><p>A clustering algorithm tuned and debugged on real (messy) browsing data</p>
</li>
<li><p>An AI labeling layer using Claude, with a grounding step that uses brand data from context.dev</p>
</li>
<li><p>A chat assistant that reasons across your threads and tells you what to do next</p>
</li>
<li><p>A polished dashboard with onboarding, a design system, and a working pipeline status machine</p>
</li>
</ul>
<p>Everything runs on-device, and the only network calls are optional and opt-in, made with your own API keys.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-openloops-is-structured">How openloops Is Structured</a></p>
<ul>
<li><p><a href="#heading-the-shared-types">The shared types</a></p>
</li>
<li><p><a href="#heading-the-manifest">The manifest</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-scaffold-the-extension">How to Scaffold the Extension</a></p>
</li>
<li><p><a href="#heading-how-to-capture-your-browsing-history">How to Capture Your Browsing History</a></p>
<ul>
<li><p><a href="#heading-a-few-shared-helpers">A few shared helpers</a></p>
</li>
<li><p><a href="#heading-the-database-layer-so-far">The database layer (so far)</a></p>
</li>
<li><p><a href="#heading-capturing-new-visits-live">Capturing new visits live</a></p>
</li>
<li><p><a href="#heading-backfilling-14-days-of-history">Backfilling 14 days of history</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-turn-noise-into-sessions">How to Turn Noise into Sessions</a></p>
<ul>
<li><p><a href="#heading-filtering-out-noise">Filtering out noise</a></p>
</li>
<li><p><a href="#heading-extracting-keywords">Extracting keywords</a></p>
</li>
<li><p><a href="#heading-extending-the-database-for-sessions">Extending the database for sessions</a></p>
</li>
<li><p><a href="#heading-segmenting-events-into-sessions">Segmenting events into sessions</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-cluster-sessions-into-intent-threads">How to Cluster Sessions into Intent Threads</a></p>
<ul>
<li><p><a href="#heading-detecting-ambient-domains">Detecting ambient domains</a></p>
</li>
<li><p><a href="#heading-extending-the-database-for-intent-threads">Extending the database for intent threads</a></p>
</li>
<li><p><a href="#heading-clustering-sessions-into-threads">Clustering sessions into threads</a></p>
</li>
<li><p><a href="#heading-scoring-and-classifying-threads">Scoring and classifying threads</a></p>
</li>
<li><p><a href="#heading-putting-it-together">Putting it together</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-clean-up-self-referential-noise">How to Clean Up Self-Referential Noise</a></p>
<ul>
<li><p><a href="#heading-the-two-problems">The two problems</a></p>
</li>
<li><p><a href="#heading-one-definition-applied-everywhere">One definition, applied everywhere</a></p>
</li>
<li><p><a href="#heading-defending-the-enrichment-boundary-too">Defending the enrichment boundary too</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-label-threads-with-claude">How to Label Threads with Claude</a></p>
<ul>
<li><p><a href="#heading-storing-keys-locally">Storing keys locally</a></p>
</li>
<li><p><a href="#heading-the-first-version-and-how-it-broke">The first version, and how it broke</a></p>
</li>
<li><p><a href="#heading-batching-the-requests">Batching the requests</a></p>
</li>
<li><p><a href="#heading-building-the-prompt-and-merging-results">Building the prompt and merging results</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-ground-labels-with-contextdev">How to Ground Labels with context.dev</a></p>
<ul>
<li><p><a href="#heading-what-the-api-returns">What the API returns</a></p>
</li>
<li><p><a href="#heading-fetching-one-brand">Fetching one brand</a></p>
</li>
<li><p><a href="#heading-enriching-domains-in-batches">Enriching domains in batches</a></p>
</li>
<li><p><a href="#heading-how-grounding-feeds-back-into-labeling">How grounding feeds back into labeling</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-design-the-dashboard">How to Design the Dashboard</a></p>
<ul>
<li><p><a href="#heading-the-three-column-layout">The three-column layout</a></p>
</li>
<li><p><a href="#heading-the-pipeline-state-machine">The pipeline state machine</a></p>
</li>
<li><p><a href="#heading-driving-the-welcome-screen-from-the-same-machine">Driving the welcome screen from the same machine</a></p>
</li>
<li><p><a href="#heading-wiring-the-handlers">Wiring the handlers</a></p>
</li>
<li><p><a href="#heading-the-resume-button">The Resume button</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-build-the-ai-assistant">How to Build the AI Assistant</a></p>
<ul>
<li><p><a href="#heading-grounding-the-conversation">Grounding the conversation</a></p>
</li>
<li><p><a href="#heading-sending-a-message">Sending a message</a></p>
</li>
<li><p><a href="#heading-model-and-effort-controls">Model and effort controls</a></p>
</li>
<li><p><a href="#heading-rendering-replies-and-the-empty-state">Rendering replies and the empty state</a></p>
</li>
<li><p><a href="#heading-checkpoint">Checkpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-youve-built-and-where-to-take-it">What You've Built, and Where to Take It</a></p>
<ul>
<li><p><a href="#heading-what-the-privacy-model-adds-up-to">What the privacy model adds up to</a></p>
</li>
<li><p><a href="#heading-where-to-take-it-next">Where to take it next</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping up</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
<ul>
<li><p><a href="#heading-source-code">Source code</a></p>
</li>
<li><p><a href="#heading-core-documentation">Core documentation</a></p>
</li>
<li><p><a href="#heading-services-used">Services used</a></p>
</li>
<li><p><a href="#heading-build-tooling">Build tooling</a></p>
</li>
<li><p><a href="#heading-debugging-tools">Debugging tools</a></p>
</li>
<li><p><a href="#heading-further-reading">Further reading</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>On first run, openloops greets you with a centered welcome screen that walks you through the three pipeline steps:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/70b376c4-e08d-45c3-9526-cad948d7bc08.png" alt="openloops welcome screen, showing the three onboarding steps: scan your history, build sessions, and build your intent map" style="display:block;margin:0 auto" width="3456" height="2162" loading="lazy">

<p>Once you've scanned your history, built sessions, and built the intent map, your browsing reorganizes into status-grouped threads: active, stalled, and dormant. Each one has a confidence score, a plain-language summary, a concrete next step, and a <strong>Resume</strong> button that reopens the exact pages you left off on. The right column holds a chat assistant grounded in your own threads:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/15e4d096-76a0-44f6-9a90-d0bb4de20bb8.png" alt="openloops dashboard showing status-grouped intent threads on the left and an AI assistant chat reasoning about what to close this week on the right" style="display:block;margin:0 auto" width="3456" height="2164" loading="lazy">

<p>That assistant response reasons across the user's actual threads, ranking them by how easy they are to close against how much of a real decision they still need. It also explains why, which is the most novel part of this build, and depends on the context.dev grounding step you'll add later in this tutorial.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you'll need:</p>
<ul>
<li><p><strong>Node 18+</strong> and a Chromium-based browser (Chrome, Brave, Edge, and so on).</p>
</li>
<li><p>Comfort with <strong>TypeScript</strong> and <strong>React</strong>. You don't need to be an expert, but you should be comfortable reading hooks and async/await.</p>
</li>
<li><p>Basic familiarity with <strong>IndexedDB</strong> is helpful but not required, as you'll learn what you need as you go.</p>
</li>
</ul>
<p>Two parts of this build are optional and require your own API key, each with a free tier:</p>
<ul>
<li><p>An <strong>Anthropic API key</strong> (from <a href="https://platform.claude.com/settings/keys">platform.claude.com</a>) for AI labeling and the chat assistant</p>
</li>
<li><p>A <strong>context.dev API key</strong> (from <a href="https://www.context.dev/login">context.dev</a>) for the brand-grounding step</p>
</li>
</ul>
<p>You can build and use the entire core pipeline, capture, clustering, scoring, without either key, since both are additive layers on top of it.</p>
<h2 id="heading-how-openloops-is-structured">How openloops Is Structured</h2>
<p>Before writing any code, it helps to see the whole shape of the thing. Every stage of openloops reads from one IndexedDB store and writes to the next:</p>
<pre><code class="language-plaintext">chrome.history (backfill) ──┐
chrome.tabs.onUpdated (live)─┴─→ raw_events
                                     │  noise filter
                                     ▼
                                  sessions
                                     │  ambient detection + clustering + scoring
                                     ▼
                               intent_threads
                                     │
                                     ▼
                              React dashboard
                                     │  optional, opt-in
                                     ├──→ brand enrichment   (context.dev)
                                     └──→ AI labeling + next step (Claude)
                                              │
                                              ▼  optional, opt-in
                                        AI assistant chat (Claude)
</code></pre>
<p>Each stage is a separate module under <code>src/pipeline/</code>, and each one is independently inspectable: you can open Chrome DevTools, look at <code>raw_events</code>, <code>sessions</code>, or <code>intent_threads</code> directly in the Application tab, and rebuild any single stage without touching the others.</p>
<h3 id="heading-the-shared-types">The Shared Types</h3>
<p>Every stage consumes and produces the same handful of TypeScript interfaces, defined once in <code>src/types.ts</code>:</p>
<pre><code class="language-typescript">// Shared TypeScript interfaces for the openloops pipeline.
// Each stage of the pipeline consumes and produces these types.

export interface RawEvent {
  id: string;
  url: string;
  domain: string;
  title: string;
  visitedAt: number;         // epoch ms
  source: "backfill" | "live";
}

export interface Session {
  id: string;
  events: RawEvent[];
  startedAt: number;
  endedAt: number;
  domains: string[];
  keywords: string[];
}

export interface IntentThread {
  id: string;
  title: string;
  summary?: string;
  nextStep?: string;   // one concrete action to move the thread forward
  sessions: Session[];
  type: "buying" | "research" | "planning" | "learning" | "unclassified";
  confidence: number;        // 0-1
  status: "active" | "stalled" | "dormant";
  firstSeen: number;
  lastSeen: number;
  distinctDays: number;
  signals: string[];
}

export interface Brand {
  domain: string;
  name: string;
  description: string;
  industry: string;
  logoUrl: string;
  brandColor: string;
}
</code></pre>
<p>Most fields on <code>IntentThread</code>, <code>confidence</code>, <code>status</code>, <code>signals</code>, and <code>distinctDays</code> get filled in by pure local heuristics later in this guide, when you cluster and score threads. <code>summary</code> and <code>nextStep</code> stay <code>undefined</code> until the optional AI labeling step, covered after that, fills them in.</p>
<p>This is the pattern that makes the whole project work: the core data model functions on its own, and AI makes it richer.</p>
<h3 id="heading-the-manifest">The Manifest</h3>
<p>openloops is a Manifest V3 extension with three permissions and three host permissions:</p>
<pre><code class="language-json">{
  "manifest_version": 3,
  "name": "openloops",
  "version": "0.0.1",
  "description": "Reconstruct your browsing history into an AI-labeled map of intent threads: active decisions, stalled research, open questions. Fully local.",

  "permissions": ["history", "tabs", "storage"],
  "host_permissions": [
    "https://api.anthropic.com/*",
    "https://api.context.dev/*",
    "https://logos.context.dev/*"
  ],

  "background": {
    "service_worker": "src/background.ts",
    "type": "module"
  },

  "options_page": "src/dashboard/index.html",

  "icons": {
    "16": "icons/icon16.png",
    "32": "icons/icon32.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },

  "action": {
    "default_title": "openloops",
    "default_icon": {
      "16": "icons/icon16.png",
      "32": "icons/icon32.png"
    }
  }
}
</code></pre>
<p>The permissions, host permissions, and <code>options_page</code> entry each carry specific weight:</p>
<ul>
<li><p><code>permissions: ["history", "tabs", "storage"]</code> are the only permissions the <em>core pipeline</em> needs. <code>history</code> reads your browsing history for the backfill, <code>tabs</code> lets the service worker observe new page loads and lets "Resume" reopen tabs, and <code>storage</code> is where API keys and preferences live.</p>
</li>
<li><p><code>host_permissions</code> are separate, and only matter if you use the optional AI features. They're what let the dashboard make <code>fetch()</code> calls to Anthropic and context.dev without hitting CORS errors.</p>
</li>
<li><p><code>options_page</code> points at the dashboard. Setting it this way, instead of a <code>default_popup</code>, means clicking the toolbar icon opens the dashboard as a full browser tab rather than a tiny popup, which matters once you're looking at a multi-column layout with status-grouped cards and a chat panel.</p>
</li>
</ul>
<h2 id="heading-how-to-scaffold-the-extension">How to Scaffold the Extension</h2>
<p>Start with Vite and the <a href="https://crxjs.dev/vite-plugin">CRXJS plugin</a>, which compiles a Manifest V3 extension with hot module reloading:</p>
<pre><code class="language-bash">npm create vite@latest openloops -- --template react-ts
cd openloops
npm install @crxjs/vite-plugin idb react-markdown
</code></pre>
<p>Your <code>vite.config.ts</code> wires CRXJS to your <code>manifest.json</code>, and from there, Vite handles compiling <code>src/background.ts</code> to a real <code>.js</code> file that Chrome can load (a raw <code>.ts</code> service worker path in the manifest will fail with a registration error, which we'll debug in the next section).</p>
<p>The dashboard's entry point is a standard React 18 root:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;openloops&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;div id="root"&gt;&lt;/div&gt;
    &lt;script type="module" src="./main.tsx"&gt;&lt;/script&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<pre><code class="language-typescriptreact">import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./app.css";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  &lt;StrictMode&gt;
    &lt;App /&gt;
  &lt;/StrictMode&gt;
);
</code></pre>
<p>Build it, then load it as an unpacked extension:</p>
<pre><code class="language-bash">npm run build
</code></pre>
<p>In Chrome, go to <code>chrome://extensions</code>, enable <strong>Developer mode</strong>, click <strong>Load unpacked</strong>, and select the <code>dist/</code> folder. With nothing else built yet, clicking the toolbar icon should open a blank dashboard tab, and the service worker (visible from the extension card's "service worker" link) should log <code>[openloops] Extension installed.</code> on install.</p>
<p>With that foundation in place, it's time to start filling <code>raw_events</code> with your actual browsing history.</p>
<h2 id="heading-how-to-capture-your-browsing-history">How to Capture Your Browsing History</h2>
<p>Every record in openloops starts life as a <code>RawEvent</code>, the type you saw earlier: a URL, a domain, a title, a timestamp, and a <code>source</code> of either <code>"backfill"</code> or <code>"live"</code>.</p>
<p>Two pipelines populate it:</p>
<ul>
<li><p>A <strong>one-time backfill</strong> that reads your last 14 days of <code>chrome.history</code> on demand</p>
</li>
<li><p><strong>Live capture</strong>, which listens for new page loads from this point forward</p>
</li>
</ul>
<p>Both paths share a handful of small helpers and write through the same IndexedDB layer, so it's worth building those first.</p>
<h3 id="heading-a-few-shared-helpers">A Few Shared Helpers</h3>
<p>Create <code>src/lib/util.ts</code>:</p>
<pre><code class="language-typescript">export function isHttpUrl(url: string): boolean {
  return url.startsWith("http://") || url.startsWith("https://");
}

export function extractDomain(url: string): string {
  try {
    const { hostname } = new URL(url);
    return hostname.replace(/^www\./, "");
  } catch {
    return url;
  }
}

export function isLocalHost(domain: string): boolean {
  if (domain === "localhost" || domain === "127.0.0.1") return true;
  if (domain.endsWith(".local")) return true;

  const octets = domain.split(".");
  if (octets.length === 4 &amp;&amp; octets.every((o) =&gt; /^\d{1,3}$/.test(o))) {
    const [a, b] = octets.map(Number);
    if (a === 10) return true;
    if (a === 172 &amp;&amp; b &gt;= 16 &amp;&amp; b &lt;= 31) return true;
    if (a === 192 &amp;&amp; b === 168) return true;
  }

  return false;
}

export function hashId(url: string, visitedAt: number): string {
  const str = `\({url}|\){visitedAt}`;
  let hash = 5381;
  for (let i = 0; i &lt; str.length; i++) {
    hash = ((hash &lt;&lt; 5) + hash) ^ str.charCodeAt(i);
    hash |= 0;
  }
  return (hash &gt;&gt;&gt; 0).toString(36);
}
</code></pre>
<p>Each of these four functions solves a problem you won't notice until later in the build:</p>
<ul>
<li><p><code>isHttpUrl</code> is the shared scheme guard used by both live capture and the backfill, and the single gate that keeps <code>chrome://</code>, <code>chrome-extension://</code>, <code>about:</code>, and <code>file://</code> URLs out of your data entirely. Both capture paths call it before anything else.</p>
</li>
<li><p><code>extractDomain</code> strips a leading <code>www.</code> and returns the hostname, which is a simplification: <a href="http://bbc.co.uk"><code>bbc.co.uk</code></a> and <a href="http://news.bbc.co.uk"><code>news.bbc.co.uk</code></a> wouldn't collapse to the same domain under this logic, since true registrable-domain extraction needs the <a href="https://publicsuffix.org/">Public Suffix List</a>. If the URL is malformed, it just returns the input unchanged rather than throwing.</p>
</li>
<li><p><code>isLocalHost</code> exists for one reason: when you add brand enrichment later in this guide, you'll be sending domain names to an external API. <code>localhost:5173</code> or <code>192.168.1.50</code> are meaningless to that API and would just be wasted lookups, so it's better to filter them here, once, at the source. It checks for <code>localhost</code>, <code>127.0.0.1</code>, <code>.local</code> hostnames, and the standard private IPv4 ranges (<code>10.x.x.x</code>, <code>172.16.x.x</code>–<code>172.31.x.x</code>, <code>192.168.x.x</code>).</p>
</li>
<li><p><code>hashId</code> combines the URL and timestamp into a short, deterministic string using a simple hashing algorithm (djb2), so the same <code>(url, visitedAt)</code> pair always produces the same ID. This makes writes idempotent: re-running the backfill produces the <em>same</em> IDs for the <em>same</em> visits, so IndexedDB's <code>put</code> overwrites cleanly instead of duplicating, which is what makes "Scan my history" safe to click more than once.</p>
</li>
</ul>
<h3 id="heading-the-database-layer-so-far">The Database Layer (So Far)</h3>
<p>openloops stores everything in IndexedDB via the <a href="https://github.com/jakearchibald/idb"><code>idb</code></a> wrapper, which gives you a typed, promise-based API over the raw IndexedDB calls. Create <code>src/db/index.ts</code>:</p>
<pre><code class="language-typescript">import { openDB, type DBSchema, type IDBPDatabase } from "idb";
import type { RawEvent } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
}

const DB_NAME = "openloops";
const DB_VERSION = 1;

let _db: Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; | null = null;

export function getDB(): Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; {
  if (!_db) {
    _db = openDB&lt;OpenloopsDB&gt;(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
      },
    });
  }
  return _db;
}

export async function clearEvents(): Promise&lt;void&gt; {
  const db = await getDB();
  return db.clear("raw_events");
}

export async function putEvents(events: RawEvent[]): Promise&lt;void&gt; {
  if (events.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("raw_events", "readwrite");
  await Promise.all([...events.map((e) =&gt; tx.store.put(e)), tx.done]);
}

export async function getAllEvents(): Promise&lt;RawEvent[]&gt; {
  const db = await getDB();
  return db.getAllFromIndex("raw_events", "by_visitedAt");
}

export async function getEventCount(): Promise&lt;number&gt; {
  const db = await getDB();
  return db.count("raw_events");
}
</code></pre>
<p>Four small functions round out this first version of the database layer: <code>clearEvents</code> wipes the store, which the backfill calls first so every scan starts from a clean snapshot. <code>putEvents</code> writes a batch using IDB's <code>put</code>, which overwrites rather than duplicates. <code>getAllEvents</code> returns everything sorted by <code>visitedAt</code> via the index. And <code>getEventCount</code> returns a simple count for the dashboard.</p>
<p><code>_db</code> is a module-level singleton promise, so every part of the extension, the service worker and the dashboard alike, shares one connection. <code>DB_VERSION</code> starts at <code>1</code> here. As you add sessions, intent threads, and brand data in later parts, you'll add new stores guarded by <code>if (!db.objectStoreNames.contains(...))</code> and bump this number. That guard means existing users upgrade safely without touching stores that already exist.</p>
<h3 id="heading-capturing-new-visits-live">Capturing New Visits Live</h3>
<p>The service worker is the always-on part of the extension. Create <code>src/background.ts</code>:</p>
<pre><code class="language-typescript">import { hashId, extractDomain, isHttpUrl } from "./lib/util";
import { putEvents } from "./db/index";
import type { RawEvent } from "./types";

chrome.runtime.onInstalled.addListener(() =&gt; {
  console.log("[openloops] Extension installed.");
});

chrome.action.onClicked.addListener(() =&gt; {
  chrome.runtime.openOptionsPage();
});

const DEDUP_MS = 3_000;
const recentCaptures = new Map&lt;number, { url: string; at: number }&gt;();

chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) =&gt; {
  if (changeInfo.status !== "complete" || !tab.url) return;

  const url = tab.url;

  if (!isHttpUrl(url)) return;

  const last = recentCaptures.get(tabId);
  const now = Date.now();
  if (last &amp;&amp; last.url === url &amp;&amp; now - last.at &lt; DEDUP_MS) {
    console.log(`[openloops] dedup skip — tab \({tabId} \){url}`);
    return;
  }

  recentCaptures.set(tabId, { url, at: now });

  const event: RawEvent = {
    id: hashId(url, now),
    url,
    domain: extractDomain(url),
    title: tab.title ?? url,
    visitedAt: now,
    source: "live",
  };

  putEvents([event]).then(() =&gt; {
    console.log(`[openloops] captured \({event.domain} — \){event.title}`);
  }).catch((err) =&gt; {
    console.error("[openloops] putEvents failed:", err);
  });
});
</code></pre>
<p><code>chrome.action.onClicked</code> is what makes the toolbar icon open the dashboard as a tab rather than a popup, working together with the <code>options_page</code> entry in your manifest.</p>
<p>Live capture happens inside the <code>tabs.onUpdated</code> listener, which Chrome fires repeatedly as a page loads, redirects, and updates its title, though you should only care about the moment <code>changeInfo.status === "complete"</code>. From there, <code>isHttpUrl</code> drops anything that isn't a real web page, the dedup guard collapses the duplicate "complete" events that SPAs love to fire, and the rest becomes a <code>RawEvent</code> with <code>source: "live"</code>.</p>
<p>That dedup guard is best-effort by design: <code>recentCaptures</code> is a plain in-memory <code>Map</code>, and Chrome can suspend the service worker between events, which wipes the <code>Map</code> along with it. It still collapses duplicate bursts within a single waking session, just not across service worker restarts, and that's an acceptable tradeoff since <code>hashId</code> already makes any duplicate that slips through harmless once it reaches IndexedDB.</p>
<p>The final write also looks slightly unusual: <code>putEvents([event]).then(...).catch(...)</code> instead of <code>await</code>. The listener doesn't need to block on the write finishing, and the service worker stays alive long enough to complete a single IndexedDB write even if it's about to be suspended, so firing the write and moving on is enough.</p>
<p>That <code>source</code> field carries more weight than it first appears, since it's how later code distinguishes "the user actually scanned their history" from "the extension has only been open for five minutes". This matters for onboarding when you design the dashboard later in this guide.</p>
<p>Build and reload the extension now (<code>npm run build</code>, then click the reload icon on the extension card in <code>chrome://extensions</code>), browse a few pages, then open the service worker's DevTools by clicking "service worker" on the extension card. You'll be able to see <code>[openloops] captured ...</code> log lines appear as confirmation that live capture is working.</p>
<h3 id="heading-backfilling-14-days-of-history">Backfilling 14 Days of History</h3>
<p>Live capture only sees what happens <em>after</em> you install the extension, so to make openloops useful immediately, you also need to backfill recent history. Create <code>src/pipeline/backfill.ts</code>:</p>
<pre><code class="language-typescript">import { extractDomain, hashId, isHttpUrl } from "../lib/util";
import { putEvents, clearEvents } from "../db/index";
import type { RawEvent } from "../types";

const CONCURRENCY = 50;

async function visitsForItem(
  item: chrome.history.HistoryItem,
  startTime: number
): Promise&lt;RawEvent[]&gt; {
  if (!item.url) return [];
  if (!isHttpUrl(item.url)) return [];

  const visits = await chrome.history.getVisits({ url: item.url });

  const events: RawEvent[] = [];
  for (const visit of visits) {
    if (!visit.visitTime || visit.visitTime &lt; startTime) continue;

    events.push({
      id: hashId(item.url, visit.visitTime),
      url: item.url,
      domain: extractDomain(item.url),
      title: item.title ?? item.url,
      visitedAt: visit.visitTime,
      source: "backfill",
    });
  }

  return events;
}

export async function backfillHistory(days = 14): Promise&lt;number&gt; {
  await clearEvents();

  const startTime = Date.now() - days * 24 * 60 * 60 * 1000;

  const historyItems = await chrome.history.search({
    text: "",
    startTime,
    maxResults: 100_000,
  });

  let totalWritten = 0;

  for (let i = 0; i &lt; historyItems.length; i += CONCURRENCY) {
    const batch = historyItems.slice(i, i + CONCURRENCY);
    const batchResults = await Promise.all(
      batch.map((item) =&gt; visitsForItem(item, startTime))
    );
    const events = batchResults.flat();
    await putEvents(events);
    totalWritten += events.length;
  }

  return totalWritten;
}
</code></pre>
<p><code>backfillHistory</code> starts by calling <code>clearEvents</code> and wiping the store so each run produces a clean snapshot for the chosen window. Every real visit still exists in <code>chrome.history</code>, so nothing is lost by starting over. It then searches with <code>maxResults: 100_000</code>, since the default of 100 is far too low for anyone with more than a few days of real browsing.</p>
<p>Each matching <code>HistoryItem</code> goes through <code>visitsForItem</code>, which skips items that Chrome returns with no <code>url</code> at all, a quirk of some deleted-history entries, and skips non-web URLs using <code>isHttpUrl</code>, before fetching that item's full visit list.</p>
<p>Calling <code>getVisits</code> here, instead of relying on <code>search</code> alone, matters because <code>chrome.history.search</code> is tempting as a single call, but it collapses every visit to a URL down to just the <em>most recent</em> one. If you visited the same Stack Overflow answer three times over two days while debugging something, <code>search</code> gives you one row, and in the next section, where you segment events into sessions, you need all three: that's the difference between "one visit, three days ago" and "a sustained debugging session."</p>
<p><code>getVisits</code> gives you that full timestamp list, but it returns <em>all</em> history for a URL regardless of date range, so <code>visitsForItem</code> filters by <code>startTime</code> itself. And because <code>chrome.history.search</code> can return tens of thousands of items for a heavy browser history, the backfill fans out to <code>getVisits</code> in batches of <code>CONCURRENCY</code>, set to 50, rather than firing everything at once. Chrome doesn't document a hard limit on concurrent <code>getVisits</code> calls, but 50 in flight at a time keeps things responsive without flooding it.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>You can verify live capture by browsing normally and watching <code>raw_events</code> fill up: open <code>chrome://extensions</code>, click "service worker" on the openloops card, then go to the <strong>Application</strong> tab → <strong>IndexedDB</strong> → <code>openloops</code> → <code>raw_events</code>, where each row should be a <code>RawEvent</code> with <code>source: "live"</code>.</p>
<p><code>backfillHistory</code> itself doesn't have a UI yet, but you'll wire it up to a "Scan my history" button when you build the dashboard rail in Part 13. For now, it's enough that it compiles and that <code>raw_events</code> is filling up from live capture. In the next part you'll start turning that raw stream into something structured: sessions.</p>
<h2 id="heading-how-to-turn-noise-into-sessions">How to Turn Noise into Sessions</h2>
<p>A real browsing history is full of activity that has nothing to do with what you were actually trying to do. An afternoon of research might be interleaved with dozens of visits to Gmail, Slack, or YouTube, along with pages whose titles are just "New Tab" or "Dashboard" because the page hadn't finished loading when the browser recorded it.</p>
<p>Before any of this can be grouped into something meaningful, two things need to happen: the noise needs to be filtered out, and what remains needs to be broken into sessions, contiguous stretches of activity separated by gaps in time.</p>
<p>This section builds both of those steps, along with a small keyword extractor that each session uses to describe what it was about, since that description is what later powers clustering.</p>
<h3 id="heading-filtering-out-noise">Filtering Out Noise</h3>
<p>Create <code>src/pipeline/noise.ts</code>:</p>
<pre><code class="language-typescript">import type { RawEvent } from "../types";
import { isHttpUrl, isLocalHost } from "../lib/util";

export const BLOCKED_DOMAINS: readonly string[] = [
  "mail.google.com",
  "outlook.live.com",
  "outlook.office.com",
  "calendar.google.com",
  "slack.com",
  "app.slack.com",
  "discord.com",
  "web.whatsapp.com",
  "teams.microsoft.com",
  "messenger.com",
];

export const ADULT_DOMAINS: readonly string[] = [
  "xvideos.com",
  "pornhub.com",
  "xnxx.com",
  "xhamster.com",
  "redtube.com",
  "youporn.com",
  "spankbang.com",
];

export const JUNK_DOMAINS: readonly string[] = [
  "trk.myperfect2give.com",
  "t.buenotraffic.com",
  "bwredir.com",
  "osom.saintscommunity.net",
];

const ALL_BLOCKED = [...BLOCKED_DOMAINS, ...ADULT_DOMAINS, ...JUNK_DOMAINS];

function domainIsBlocked(domain: string): boolean {
  return ALL_BLOCKED.some(
    (blocked) =&gt; domain === blocked || domain.endsWith("." + blocked)
  );
}

export const NOISE_TITLE_PREFIXES: readonly string[] = [
  "new tab",
  "new chat",
  "untitled",
  "inbox",
  "home",
  "dashboard",
  "sign in",
  "log in",
  "loading",
];

function titleIsGeneric(title: string, domain: string): boolean {
  if (title.trim() === "") return true;
  if (title.toLowerCase() === domain.toLowerCase()) return true;

  const lower = title.toLowerCase();
  return NOISE_TITLE_PREFIXES.some((prefix) =&gt; lower.startsWith(prefix));
}

export function isNoise(event: RawEvent): boolean {
  if (!isHttpUrl(event.url)) return true;
  if (isLocalHost(event.domain)) return true;
  return domainIsBlocked(event.domain) || titleIsGeneric(event.title, event.domain);
}
</code></pre>
<p><code>isNoise</code> is the single function the rest of the pipeline calls, and it layers four checks on top of each other, each one catching a different kind of noise.</p>
<p>The first two checks reuse the helpers from earlier: <code>isHttpUrl</code> and <code>isLocalHost</code> drop anything that isn't a real web page or that points at a local development server, the same filters that already protect capture. Checking them again here is a deliberate belt-and-suspenders measure: if anything ever reaches <code>raw_events</code> without having passed through capture's checks, it still can't make it into a session.</p>
<p><code>BLOCKED_DOMAINS</code> covers communication and productivity tools, Gmail, Slack, Discord, WhatsApp Web, and similar. Those tools that you visit constantly but that carry no research intent of their own. <code>domainIsBlocked</code> matches both the exact domain and any subdomain, so <code>slack.com</code> in the list also catches <code>app.slack.com</code>. <code>ADULT_DOMAINS</code> and <code>JUNK_DOMAINS</code> exist for related reasons, keeping adult content and known tracker or redirect domains out of your threads entirely.</p>
<p><code>BLOCKED_DOMAINS</code> is a curated, static list, and later in this guide it's complemented by a second, frequency-based detector in <code>ambient.ts</code>. This drops any domain that shows up in nearly every session regardless of what that domain actually is.</p>
<p>The last check, <code>titleIsGeneric</code>, catches pages whose titles tell you nothing useful: an empty title, a title that's identical to the domain name, or a title that starts with a generic prefix like "New Tab", "Dashboard", "Loading...", or "Sign in". <code>NOISE_TITLE_PREFIXES</code> is matched against the start of the lowercased title, so "Dashboard | Vercel" gets dropped right alongside a bare "Dashboard", while a content-rich title on that same domain passes through untouched.</p>
<h3 id="heading-extracting-keywords">Extracting Keywords</h3>
<p>Create <code>src/pipeline/keywords.ts</code>. This isn't NLP, just frequency counting after stopword removal. This is good enough to surface something like "typescript generics" or "react hooks" from a session of related browsing:</p>
<pre><code class="language-typescript">import { BLOCKED_DOMAINS } from "./noise";

export const STOPWORDS: ReadonlySet&lt;string&gt; = new Set([
  "the", "and", "for", "with", "you", "your", "how", "what", "this", "that",
  "from", "are", "was", "not", "but", "all", "can", "has", "have", "will",
  "its", "out", "one", "get", "our", "had", "just", "about", "also", "more",
  "into", "than", "then", "when", "their", "there", "which", "would", "been",
  "his", "her", "who", "they", "she", "him", "now", "any", "way", "use",
  "using", "used", "make", "made",
  "google", "youtube", "search", "chat", "new", "home", "www", "com", "org",
  "net", "page", "site", "tab", "view", "app", "log", "sign", "login",
  "official", "free", "online", "best", "top", "open",
]);

export const PLATFORM_STOPWORDS: ReadonlySet&lt;string&gt; = new Set([
  "instagram", "facebook", "youtube", "claude", "google", "linkedin",
  "twitter", "reddit", "netflix", "amazon", "gmail", "whatsapp", "tiktok",
  "messenger",
  "stories", "story", "reel", "reels", "shorts", "short", "feed", "watch",
  "video", "videos", "music", "post", "posts", "message", "messages",
  "dm", "dms", "notification", "notifications", "profile", "home", "login",
  "signin", "follow", "followers",
]);

function derivedDomainLabels(): Set&lt;string&gt; {
  const labels = new Set&lt;string&gt;();
  for (const domain of BLOCKED_DOMAINS) {
    const label = domain.split(".").at(-2);
    if (label) labels.add(label);
  }
  return labels;
}

const ALL_STOP_TOKENS: ReadonlySet&lt;string&gt; = new Set([
  ...STOPWORDS,
  ...PLATFORM_STOPWORDS,
  ...derivedDomainLabels(),
]);

export function extractKeywords(titles: string[], max = 8): string[] {
  const freq = new Map&lt;string, number&gt;();

  for (const title of titles) {
    const tokens = title.toLowerCase().split(/[^a-z0-9]+/);
    for (const token of tokens) {
      if (token.length &lt; 3) continue;
      if (/^\d+$/.test(token)) continue;
      if (ALL_STOP_TOKENS.has(token)) continue;

      freq.set(token, (freq.get(token) ?? 0) + 1);
    }
  }

  return [...freq.entries()]
    .sort((a, b) =&gt; b[1] - a[1])
    .slice(0, max)
    .map(([token]) =&gt; token);
}
</code></pre>
<p><code>extractKeywords</code> takes the page titles from a group of events and returns the handful of words that show up most often, after stripping out everything that isn't a topic. That stripping is doing more work than the name "stopwords" suggests.</p>
<p><code>STOPWORDS</code> covers common English function words like "the" and "with", plus generic site chrome like "search", "login", and "page". On its own, this would still let through tokens like "instagram" or "reels" from a title such as "Reels · Instagram", and those tokens would then show up as keywords for that session.</p>
<p>That gap is what <code>PLATFORM_STOPWORDS</code> closes. A title like "Reels · Instagram" or "Watch - YouTube" identifies the tool you were using, not what you were doing with it. So <code>PLATFORM_STOPWORDS</code> strips out platform and brand names along with social media UI chrome like "stories", "feed", "dm", and "notifications". Without this list, sessions on social platforms would extract keywords like "instagram" or "watch". Those would become thread titles that quietly pull unrelated sessions together during clustering, since every social-media session would share that one meaningless keyword.</p>
<p><code>derivedDomainLabels</code> keeps a third source of stopwords in sync automatically: for every domain in <code>BLOCKED_DOMAINS</code>, it takes the label immediately before the top-level domain. So <code>mail.google.com</code> becomes <code>google</code> and <code>web.whatsapp.com</code> becomes <code>whatsapp</code>. Adding a new domain to that blocklist later also prevents its name from polluting keywords, without any extra bookkeeping.</p>
<p>With all three sets merged once at module load into <code>ALL_STOP_TOKENS</code>, <code>extractKeywords</code> itself is straightforward: lowercase every title, split on anything that isn't a letter or digit, drop tokens shorter than three characters or made entirely of digits, and drop anything in <code>ALL_STOP_TOKENS</code>. Then count what's left and return the most frequent entries.</p>
<h3 id="heading-extending-the-database-for-sessions">Extending the Database For Sessions</h3>
<p>Sessions need a place to live. Earlier in this guide, <code>src/db/index.ts</code> defined a schema with just <code>raw_events</code> at version 1. We'll add a <code>sessions</code> store and bump the version to 2.</p>
<p>First, extend the schema and the <code>upgrade</code> callback:</p>
<pre><code class="language-typescript">import type { RawEvent, Session } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
  sessions: {
    key: string;
    value: Session;
    indexes: { by_startedAt: number };
  };
}

const DB_VERSION = 2;

export function getDB(): Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; {
  if (!_db) {
    _db = openDB&lt;OpenloopsDB&gt;(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
        if (!db.objectStoreNames.contains("sessions")) {
          const s = db.createObjectStore("sessions", { keyPath: "id" });
          s.createIndex("by_startedAt", "startedAt");
        }
      },
    });
  }
  return _db;
}
</code></pre>
<p>Then add the helper functions sessions need, alongside the <code>raw_events</code> helpers you already wrote. They follow the same shape: <code>putSessions</code> writes a batch idempotently, <code>clearSessions</code> wipes the store before a rebuild, <code>getAllSessions</code> returns everything sorted by <code>startedAt</code> via the index, and <code>getSessionCount</code> returns a total.</p>
<pre><code class="language-typescript">export async function putSessions(sessions: Session[]): Promise&lt;void&gt; {
  if (sessions.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("sessions", "readwrite");
  await Promise.all([...sessions.map((s) =&gt; tx.store.put(s)), tx.done]);
}

export async function clearSessions(): Promise&lt;void&gt; {
  const db = await getDB();
  return db.clear("sessions");
}

export async function getAllSessions(): Promise&lt;Session[]&gt; {
  const db = await getDB();
  return db.getAllFromIndex("sessions", "by_startedAt");
}

export async function getSessionCount(): Promise&lt;number&gt; {
  const db = await getDB();
  return db.count("sessions");
}
</code></pre>
<p>The <code>if (!db.objectStoreNames.contains(...))</code> guard from earlier is what makes this safe: anyone who already has a version-1 database, with <code>raw_events</code> full of real data, gets the new <code>sessions</code> store added on top, without touching what's already there.</p>
<h3 id="heading-segmenting-events-into-sessions">Segmenting Events into Sessions</h3>
<p>A session is a contiguous block of browsing activity, with a new one starting whenever the gap between two consecutive events exceeds <code>SESSION_GAP_MS</code>. Create <code>src/pipeline/sessions.ts</code>:</p>
<pre><code class="language-typescript">import { getAllEvents, clearSessions, putSessions } from "../db/index";
import { isNoise } from "./noise";
import { extractKeywords } from "./keywords";
import { hashId } from "../lib/util";
import type { RawEvent, Session } from "../types";

const SESSION_GAP_MS = 30 * 60 * 1000;

function rankDomains(events: RawEvent[]): string[] {
  const freq = new Map&lt;string, number&gt;();
  for (const e of events) {
    freq.set(e.domain, (freq.get(e.domain) ?? 0) + 1);
  }
  return [...freq.entries()]
    .sort((a, b) =&gt; b[1] - a[1])
    .map(([domain]) =&gt; domain);
}

function buildSession(events: RawEvent[]): Session {
  const startedAt = events[0].visitedAt;
  const endedAt = events[events.length - 1].visitedAt;

  return {
    id: hashId(events[0].url, startedAt),
    events,
    startedAt,
    endedAt,
    domains: rankDomains(events),
    keywords: extractKeywords(events.map((e) =&gt; e.title)),
  };
}

export async function buildSessions(): Promise&lt;{ events: number; sessions: number }&gt; {
  const allEvents = await getAllEvents();

  const meaningful = allEvents.filter((e) =&gt; !isNoise(e));

  if (meaningful.length === 0) {
    await clearSessions();
    return { events: 0, sessions: 0 };
  }

  const sessions: Session[] = [];
  let currentGroup: RawEvent[] = [meaningful[0]];

  for (let i = 1; i &lt; meaningful.length; i++) {
    const gap = meaningful[i].visitedAt - meaningful[i - 1].visitedAt;

    if (gap &gt; SESSION_GAP_MS) {
      sessions.push(buildSession(currentGroup));
      currentGroup = [meaningful[i]];
    } else {
      currentGroup.push(meaningful[i]);
    }
  }
  sessions.push(buildSession(currentGroup));

  const substantive = sessions.filter(
    (s) =&gt; !(s.events.length === 1 &amp;&amp; s.keywords.length === 0)
  );

  await clearSessions();
  await putSessions(substantive);

  return { events: meaningful.length, sessions: substantive.length };
}
</code></pre>
<p><code>buildSessions</code> does five things in order:</p>
<ol>
<li><p>loads every raw event sorted by time,</p>
</li>
<li><p>drops anything <code>isNoise</code> flags,</p>
</li>
<li><p>walks the remaining list and starts a new session whenever the gap between two consecutive events exceeds <code>SESSION_GAP_MS</code> (pushing the final in-progress group once the loop ends since nothing else closes it off),</p>
</li>
<li><p>drops sessions that turned out to be a single event with no extractable keywords (usually stray page loads that never connected to anything else),</p>
</li>
<li><p>and persists the result.</p>
</li>
</ol>
<p>Each session's <code>domains</code> and <code>keywords</code> come from <code>rankDomains</code> and <code>extractKeywords</code> running over just the events in that group. <code>rankDomains</code> counts how many events came from each domain and orders them by frequency, so the most-visited domain in a session comes first.</p>
<p>A worked example makes "walking the list" concrete. Take five events that survive noise filtering, A through E:</p>
<pre><code class="language-plaintext">A  t= 0 min  "TypeScript generics - Stack Overflow"   stackoverflow.com
B  t= 5 min  "TypeScript Handbook"                    typescriptlang.org
C  t=10 min  "microsoft/TypeScript - GitHub"          github.com
   ↑ gap to D = 45 min  &gt;  SESSION_GAP_MS (30 min)  → SPLIT HERE
D  t=55 min  "React hooks explained - YouTube"         youtube.com
E  t=60 min  "useEffect cleanup - Stack Overflow"     stackoverflow.com
</code></pre>
<p>As the loop walks from A to B to C, each gap is under the 30-minute limit, so all three stay in the same group. The jump from C to D is 45 minutes, which crosses <code>SESSION_GAP_MS</code>, so the loop closes off <code>[A, B, C]</code> as Session 1 and starts a fresh group with D. From D to E is only 5 minutes, so E joins D, and that group becomes Session 2 once the loop ends.</p>
<p>Session 1 ends up tagged with keywords like <code>typescript</code> and <code>generics</code>, while Session 2 is tagged with <code>react</code> and <code>hooks</code>, even though both sessions happened on the same day.</p>
<p><code>SESSION_GAP_MS</code> is set to 30 minutes because that's the same default that Google Analytics and similar tools use, and it works well for most browsing patterns.</p>
<p>The tradeoff runs in both directions: a shorter gap produces more, smaller sessions, which gives clustering a more granular signal but risks fragmenting one continuous task into several pieces. A longer gap produces fewer, larger sessions, which risks merging activity that was actually unrelated.</p>
<p>30 minutes is a reasonable starting point, and it's the kind of constant you can come back and tune once you see how your own threads turn out.</p>
<h3 id="heading-checkpoint"><strong>Checkpoint</strong></h3>
<p><code>buildSessions</code> doesn't have a UI yet either. It'll get wired up to a "Build sessions" button alongside "Scan my history" when you design the dashboard later in this guide.</p>
<p>For now, the goal is just for everything in this section to compile cleanly: <code>src/pipeline/noise.ts</code>, <code>src/pipeline/keywords.ts</code>, the updated <code>src/db/index.ts</code>, and <code>src/pipeline/sessions.ts</code> should all build without errors. <code>getDB()</code> should report version 2 the next time the extension reloads (visible in DevTools under <strong>Application</strong> → <strong>IndexedDB</strong> → <code>openloops</code>, where the database now lists both <code>raw_events</code> and <code>sessions</code> as object stores).</p>
<p>With sessions in place, the next section takes this structured-but-unconnected data and groups sessions together into the intent threads this whole project is named after.</p>
<h2 id="heading-how-to-cluster-sessions-into-intent-threads">How to Cluster Sessions into Intent Threads</h2>
<p>Sessions group events that happened close together in time. But the things you're actually trying to do rarely fit inside one session. Comparing laptops might span three sessions over four days. A question you keep meaning to look into might surface for ten minutes every few days for two weeks.</p>
<p>This section groups related sessions together into intent threads, then scores each thread for how confident openloops is that it represents something real and how alive it still is.</p>
<p>Two files do this work. <code>src/pipeline/ambient.ts</code> detects domains that are part of your daily routine rather than any particular intent, so they don't create false similarity between unrelated sessions. <code>src/pipeline/threads.ts</code> does the actual clustering and scoring.</p>
<h3 id="heading-detecting-ambient-domains">Detecting Ambient Domains</h3>
<p>Some domains show up in almost every session regardless of what you're doing: <a href="http://youtube.com">youtube.com</a> as background noise, <a href="http://github.com">github.com</a> if you're a developer who commits daily, or <a href="http://claude.ai">claude.ai</a> if you use it as a general assistant. If clustering compared sessions on these domains the same way it compares them on anything else, two completely unrelated sessions would look similar just because they both touched <a href="http://youtube.com">youtube.com</a>, and everything would eventually merge into one enormous thread.</p>
<p><code>ambient.ts</code> solves this with a frequency check: a domain is ambient if it shows up on a large enough fraction of your active days, regardless of topic.</p>
<p>Create <code>src/pipeline/ambient.ts</code>:</p>
<pre><code class="language-typescript">import type { Session } from "../types";

export const UBIQUITY_THRESHOLD = 0.6;
export const MIN_ACTIVE_DAYS = 3;

function toDay(epochMs: number): string {
  return new Date(epochMs).toDateString();
}

export function detectAmbientDomains(sessions: Session[]): Set&lt;string&gt; {
  const allEvents = sessions.flatMap((s) =&gt; s.events);

  const activeDays = new Set(allEvents.map((e) =&gt; toDay(e.visitedAt)));
  const totalActiveDays = activeDays.size;

  if (totalActiveDays &lt; MIN_ACTIVE_DAYS) {
    return new Set();
  }

  const domainDayMap = new Map&lt;string, Set&lt;string&gt;&gt;();
  for (const event of allEvents) {
    const day = toDay(event.visitedAt);
    if (!domainDayMap.has(event.domain)) {
      domainDayMap.set(event.domain, new Set());
    }
    domainDayMap.get(event.domain)!.add(day);
  }

  const ambient = new Set&lt;string&gt;();
  for (const [domain, days] of domainDayMap) {
    const ubiquity = days.size / totalActiveDays;
    if (ubiquity &gt;= UBIQUITY_THRESHOLD) {
      ambient.add(domain);
      console.log(
        `[openloops] ambient: \({domain} (\){days.size}/\({totalActiveDays} days, ubiquity=\){ubiquity.toFixed(2)})`
      );
    }
  }

  return ambient;
}
</code></pre>
<p><code>toDay</code> collapses a timestamp down to a calendar-day string, so two events on the same day produce the same key, regardless of the exact time.</p>
<p><code>detectAmbientDomains</code> first counts how many distinct days had any browsing activity at all – that's <code>totalActiveDays</code> – then builds a map from each domain to the set of days it appeared on. A domain's ubiquity is <code>days.size / totalActiveDays</code>, the fraction of your active days that domain showed up on. Anything at or above <code>UBIQUITY_THRESHOLD</code> 0.6 gets added to the returned set.</p>
<p><code>MIN_ACTIVE_DAYS</code> exists because with only one or two days of data, almost every domain you visited would technically appear on 100% of your active days, and the detector would mark everything as ambient. Below three active days, it returns an empty set and skips detection entirely.</p>
<p>This approach has a real tradeoff. It correctly identifies genuinely ambient tools, but it can also suppress a domain you happened to research intensively every single day for a week, which would also cross the 60% threshold.</p>
<p><code>UBIQUITY_THRESHOLD</code> is the knob for that tradeoff: raising it reduces false positives at the cost of letting some real ambient noise back in.</p>
<h3 id="heading-extending-the-database-for-intent-threads">Extending the Database for Intent Threads</h3>
<p>Threads need their own store. Bump <code>DB_VERSION</code> to 3 and add <code>intent_threads</code>, indexed by <code>lastSeen</code>, so the dashboard can show the most recently active threads first:</p>
<pre><code class="language-typescript">import type { RawEvent, Session, IntentThread } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: {
    key: string;
    value: RawEvent;
    indexes: { by_visitedAt: number };
  };
  sessions: {
    key: string;
    value: Session;
    indexes: { by_startedAt: number };
  };
  intent_threads: {
    key: string;
    value: IntentThread;
    indexes: { by_lastSeen: number };
  };
}

const DB_VERSION = 3;

export function getDB(): Promise&lt;IDBPDatabase&lt;OpenloopsDB&gt;&gt; {
  if (!_db) {
    _db = openDB&lt;OpenloopsDB&gt;(DB_NAME, DB_VERSION, {
      upgrade(db) {
        if (!db.objectStoreNames.contains("raw_events")) {
          const s = db.createObjectStore("raw_events", { keyPath: "id" });
          s.createIndex("by_visitedAt", "visitedAt");
        }
        if (!db.objectStoreNames.contains("sessions")) {
          const s = db.createObjectStore("sessions", { keyPath: "id" });
          s.createIndex("by_startedAt", "startedAt");
        }
        if (!db.objectStoreNames.contains("intent_threads")) {
          const s = db.createObjectStore("intent_threads", { keyPath: "id" });
          s.createIndex("by_lastSeen", "lastSeen");
        }
      },
    });
  }
  return _db;
}
</code></pre>
<p>Then add the matching helpers:</p>
<pre><code class="language-typescript">export async function putThreads(threads: IntentThread[]): Promise&lt;void&gt; {
  if (threads.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("intent_threads", "readwrite");
  await Promise.all([...threads.map((t) =&gt; tx.store.put(t)), tx.done]);
}

export async function clearThreads(): Promise&lt;void&gt; {
  const db = await getDB();
  return db.clear("intent_threads");
}

export async function getAllThreads(): Promise&lt;IntentThread[]&gt; {
  const db = await getDB();
  const index = db
    .transaction("intent_threads", "readonly")
    .store.index("by_lastSeen");

  let cursor = await index.openCursor(null, "prev");
  const results: IntentThread[] = [];
  while (cursor) {
    results.push(cursor.value);
    cursor = await cursor.continue();
  }
  return results;
}

export async function getThreadCount(): Promise&lt;number&gt; {
  const db = await getDB();
  return db.count("intent_threads");
}
</code></pre>
<p><code>putThreads</code>, <code>clearThreads</code>, and <code>getThreadCount</code> follow the same pattern as the <code>sessions</code> helpers from earlier. <code>getAllThreads</code> is the odd one out: instead of <code>getAllFromIndex</code>, which only returns ascending order, it opens a cursor on <code>by_lastSeen</code> in <code>"prev"</code> direction and walks it manually. That gives you threads ordered with the most recently active first, the order the dashboard wants for status-grouped cards.</p>
<h3 id="heading-clustering-sessions-into-threads">Clustering Sessions into Threads</h3>
<p>With ambient domains identified, <code>src/pipeline/threads.ts</code> now does the real work: grouping sessions into threads, then scoring and classifying each one.</p>
<p>The approach is <a href="https://research.google/blog/scaling-hierarchical-agglomerative-clustering-to-trillion-edge-graphs/">greedy agglomerative clustering</a>. Walk through sessions in chronological order, and for each one, either merge it into the most similar existing thread or start a new thread if nothing is similar enough.</p>
<p>Start with the imports, the tuning constants, and the similarity calculation:</p>
<pre><code class="language-typescript">import { getAllSessions, clearThreads, putThreads } from "../db/index";
import { detectAmbientDomains } from "./ambient";
import { hashId } from "../lib/util";
import type { Session, IntentThread } from "../types";

export const SIMILARITY_THRESHOLD = 0.15;
export const DOMAIN_WEIGHT = 0.5;
export const KEYWORD_WEIGHT = 0.5;

interface ThreadBuilder {
  id: string;
  sessions: Session[];
  domainSet: Set&lt;string&gt;;
  keywordSet: Set&lt;string&gt;;
}

function jaccard(a: Set&lt;string&gt;, b: Set&lt;string&gt;): number {
  if (a.size === 0 &amp;&amp; b.size === 0) return 0;
  let intersection = 0;
  for (const item of a) {
    if (b.has(item)) intersection++;
  }
  const union = a.size + b.size - intersection;
  return intersection / union;
}

function similarity(
  session: Session,
  thread: ThreadBuilder,
  ambient: Set&lt;string&gt;
): number {
  const sessionDomains  = new Set(session.domains.filter((d) =&gt; !ambient.has(d)));
  const threadDomains   = new Set([...thread.domainSet].filter((d) =&gt; !ambient.has(d)));
  const sessionKeywords = new Set(session.keywords);

  const domainScore   = jaccard(sessionDomains, threadDomains);
  const keywordScore  = jaccard(sessionKeywords, thread.keywordSet);

  return DOMAIN_WEIGHT * domainScore + KEYWORD_WEIGHT * keywordScore;
}
</code></pre>
<p><code>ThreadBuilder</code> is a mutable accumulator used only during clustering: a thread in progress, with its sessions plus the union of all domains and keywords seen so far. <code>jaccard</code> is the standard set-similarity measure, the size of the intersection divided by the size of the union, returning 0 for two empty sets rather than dividing zero by zero.</p>
<p><code>similarity</code> compares one candidate session against one in-progress thread. Before comparing domains, it filters ambient domains out of both sides, so a shared <code>youtube.com</code> never contributes to the score. It then computes a domain Jaccard score and a keyword Jaccard score separately, and combines them with <code>DOMAIN_WEIGHT</code> and <code>KEYWORD_WEIGHT</code>, both 0.5, giving domain overlap and keyword overlap equal say in the final number.</p>
<p>Next, the clustering loop itself:</p>
<pre><code class="language-typescript">function clusterSessions(
  sessions: Session[],
  ambient: Set&lt;string&gt;
): ThreadBuilder[] {
  const threads: ThreadBuilder[] = [];

  for (const session of sessions) {
    let bestThread: ThreadBuilder | null = null;
    let bestScore = 0;

    for (const thread of threads) {
      const score = similarity(session, thread, ambient);
      if (score &gt; bestScore) {
        bestScore = score;
        bestThread = thread;
      }
    }

    if (bestThread &amp;&amp; bestScore &gt;= SIMILARITY_THRESHOLD) {
      bestThread.sessions.push(session);
      for (const d of session.domains)  bestThread.domainSet.add(d);
      for (const k of session.keywords) bestThread.keywordSet.add(k);
    } else {
      threads.push({
        id: hashId(session.id, session.startedAt),
        sessions: [session],
        domainSet:  new Set(session.domains),
        keywordSet: new Set(session.keywords),
      });
    }
  }

  return threads;
}
</code></pre>
<p><code>clusterSessions</code> relies on <code>sessions</code> already being sorted chronologically, which <code>getAllSessions</code> guarantees via its index. For each session, it scores against every thread built so far and keeps the best match.</p>
<p>If that best score clears <code>SIMILARITY_THRESHOLD</code>, the session merges in and its domains and keywords get folded into the thread's accumulated sets. This means that later sessions are compared against the thread's <em>entire</em> accumulated history rather than only its seed session. If nothing clears the threshold, the session becomes the seed of a brand-new thread.</p>
<p>A worked example shows how this plays out. Suppose <code>detectAmbientDomains</code> returned <code>{ youtube.com }</code>, and three sessions arrive in this order:</p>
<pre><code class="language-plaintext">S1: domains=[stackoverflow.com, typescriptlang.org]
    keywords=[typescript, generics, interface, mapped]

S2: domains=[stackoverflow.com, typescriptlang.org, github.com]
    keywords=[typescript, generics, utility, types]

S3: domains=[python.org, docs.python.org]
    keywords=[python, async, await, coroutine]
</code></pre>
<p>S1 arrives first. With no threads yet, it seeds Thread A: <code>domainSet = {stackoverflow.com, typescriptlang.org}</code>, <code>keywordSet = {typescript, generics, interface, mapped}</code>.</p>
<p>S2 is scored against Thread A. Neither set contains the ambient <code>youtube.com</code>, so nothing gets filtered out. The domain Jaccard is <code>|{stackoverflow.com, typescriptlang.org}| / |{stackoverflow.com, typescriptlang.org, github.com}|</code>, or 2/3 ≈ 0.667. The keyword Jaccard is <code>|{typescript, generics}| / |{typescript, generics, interface, mapped, utility, types}|</code>, or 2/6 ≈ 0.333. The combined similarity is <code>0.5 × 0.667 + 0.5 × 0.333 = 0.5</code>, comfortably above <code>SIMILARITY_THRESHOLD</code> (0.15), so S2 merges into Thread A, whose sets grow to include <code>github.com</code>, <code>utility</code>, and <code>types</code>.</p>
<p>S3 is scored against Thread A. There's no overlap at all between <code>{python.org, docs.python.org}</code> and Thread A's domains, or between their keyword sets, so both Jaccard scores are 0 and the combined similarity is 0. That's below the threshold, so S3 seeds a new Thread B.</p>
<p>The result: Thread A holds the TypeScript research across two sessions, and Thread B holds the Python session on its own.</p>
<p><code>SIMILARITY_THRESHOLD</code> is the single most consequential constant in this file, and 0.15 is lower than you might guess for a 50/50 weighted Jaccard score. A starting value like 0.3 sounds more principled. That would mean two sessions need to share roughly a third of their combined domains and keywords before they're considered part of the same thread.</p>
<p>Run that against real, messy browsing history, though, and it produces far too many threads: sessions that were obviously part of the same research, but didn't share quite enough keywords to clear 0.3, end up scattered across separate threads.</p>
<p>Dropping the threshold to 0.15 lets sessions merge on weaker but still real signal. Two sessions sharing just one domain and one keyword out of several can already cross 0.15, and the result is fewer, more coherent threads that actually match what the browsing history looks like.</p>
<p>This is the kind of constant you tune empirically rather than deriving it from first principles: build your threads, look at the result, and adjust.</p>
<p><code>buildThreads</code>, covered next, prints a table of every thread's title, type, status, confidence, and top keywords specifically so you can eyeball this. If two threads obviously belong together, lower <code>SIMILARITY_THRESHOLD</code>. If one thread is clearly several unrelated topics glued together, raise it.</p>
<h3 id="heading-scoring-and-classifying-threads">Scoring and Classifying Threads</h3>
<p>Clustering produces groups of sessions, but a group of sessions isn't yet an <code>IntentThread</code>. The rest of <code>threads.ts</code> turns each group into something with a type, a confidence score, a status, and a set of human-readable signals explaining why.</p>
<p>A few small helpers come first:</p>
<pre><code class="language-typescript">export const BUYING_WORDS: readonly string[] = [
  "vs", "versus", "alternative", "alternatives",
  "comparison", "pricing", "price", "review", "reviews", "best",
];

export const LEARNING_WORDS: readonly string[] = [
  "how to", "tutorial", "tutorials", "docs", "documentation",
  "guide", "learn", "example", "examples", "crash course", "introduction",
];

const STATUS_ACTIVE_MS  = 48 * 60 * 60 * 1000;
const STATUS_STALLED_MS = 7  * 24 * 60 * 60 * 1000;

function toTitleCase(s: string): string {
  return s.charAt(0).toUpperCase() + s.slice(1);
}

function findMatches(titles: string[], wordList: readonly string[]): string[] {
  const lower = titles.map((t) =&gt; t.toLowerCase());
  const found = new Set&lt;string&gt;();

  for (const word of wordList) {
    const isPhrase = word.includes(" ");
    for (const title of lower) {
      if (isPhrase) {
        if (title.includes(word)) found.add(word);
      } else {
        const tokens = title.split(/[^a-z0-9]+/);
        if (tokens.includes(word)) found.add(word);
      }
    }
  }

  return [...found];
}

function toCalendarDay(epochMs: number): string {
  return new Date(epochMs).toDateString();
}
</code></pre>
<p><code>BUYING_WORDS</code> and <code>LEARNING_WORDS</code> are small vocabularies that signal intent. <code>findMatches</code> checks a list of page titles against one of these vocabularies, and handles single words and phrases differently: a multi-word entry like "how to" is checked as a substring, since it's specific enough that false positives are unlikely. But a single word like "review" is checked as a whole token, split out of the title on non-alphanumeric characters.</p>
<p>Without that distinction, "review" would match inside "overview" too, which would misclassify any thread that happened to involve an "Overview" page. <code>toTitleCase</code> and <code>toCalendarDay</code> are small formatting helpers used by the scoring function next.</p>
<p>That scoring function, <code>scoreThread</code>, is the longest function in the project, since it's where every signal collected so far gets turned into the fields on <code>IntentThread</code>:</p>
<pre><code class="language-typescript">function scoreThread(builder: ThreadBuilder): IntentThread {
  const { sessions, keywordSet } = builder;

  const firstSeen  = sessions[0].startedAt;
  const lastSeen   = sessions[sessions.length - 1].endedAt;

  const allEvents  = sessions.flatMap((s) =&gt; s.events);
  const totalEvents = allEvents.length;
  const daySet     = new Set(allEvents.map((e) =&gt; toCalendarDay(e.visitedAt)));
  const distinctDays = daySet.size;

  const allTitles      = allEvents.map((e) =&gt; e.title);
  const buyingMatches  = findMatches(allTitles, BUYING_WORDS);
  const learningMatches = findMatches(allTitles, LEARNING_WORDS);

  let type: IntentThread["type"];
  if (buyingMatches.length &gt; 0) {
    type = "buying";
  } else if (learningMatches.length &gt; 0) {
    type = "learning";
  } else if (distinctDays &gt; 5 &amp;&amp; sessions.length &gt;= 3) {
    type = "planning";
  } else if (totalEvents &gt;= 3) {
    type = "research";
  } else {
    type = "unclassified";
  }

  const age = Date.now() - lastSeen;
  const status: IntentThread["status"] =
    age &lt; STATUS_ACTIVE_MS  ? "active"  :
    age &lt; STATUS_STALLED_MS ? "stalled" :
    "dormant";

  const confidence = parseFloat((
    Math.min(distinctDays / 5, 1) * 0.35 +
    Math.min(sessions.length / 5, 1) * 0.25 +
    Math.min(totalEvents / 20, 1)  * 0.20 +
    (type !== "unclassified" ? 1 : 0)  * 0.20
  ).toFixed(2));

  const signals: string[] = [];

  if (distinctDays &gt; 1)
    signals.push(`revisited across ${distinctDays} days`);
  if (type === "buying" &amp;&amp; buyingMatches.length &gt; 0)
    signals.push(`comparison language: ${buyingMatches.join(", ")}`);
  if (type === "learning" &amp;&amp; learningMatches.length &gt; 0)
    signals.push(`learning language: ${learningMatches.join(", ")}`);
  signals.push(`\({sessions.length} session\){sessions.length !== 1 ? "s" : ""}`);
  if (totalEvents &gt; 5)
    signals.push(`${totalEvents} total events`);
  if (type === "planning")
    signals.push("sustained activity across many days");

  const ageDays = Math.floor(age / (24 * 60 * 60 * 1000));
  if (ageDays === 0)       signals.push("last active today");
  else if (ageDays === 1)  signals.push("last active yesterday");
  else                     signals.push(`last active ${ageDays} days ago`);

  const title =
    [...keywordSet].slice(0, 3).map(toTitleCase).join(" ") || "Untitled Thread";

  return {
    id: builder.id,
    title,
    sessions,
    type,
    confidence,
    status,
    firstSeen,
    lastSeen,
    distinctDays,
    signals,
  };
}
</code></pre>
<p>There's a lot here, so it's worth walking through each field on <code>IntentThread</code> in the order it's computed.</p>
<p><code>firstSeen</code> and <code>lastSeen</code> come straight from the boundary sessions, since <code>sessions</code> arrives in chronological order from clustering. <code>distinctDays</code> reuses the same calendar-day collapsing as <code>ambient.ts</code>. This time it counts how many different days <em>this thread's</em> events span, regardless of how many total active days you had overall.</p>
<p>Classification into <code>type</code> is a cascade, and the order matters. Comparison language (<code>BUYING_WORDS</code>) is checked first, because a thread where you're comparing two frameworks is "buying" even if it also contains tutorial pages. Comparison intent is the stronger signal.</p>
<p>Learning language comes next. After that, <code>planning</code> is reserved for threads that span more than five distinct days <em>and</em> have at least three sessions of sustained, recurring activity rather than a single deep dive.</p>
<p><code>research</code> is the catch-all for anything with at least three events that didn't match anything more specific, and <code>unclassified</code> is what's left, usually threads with too little activity to say anything confident about.</p>
<p><code>status</code> is purely a function of how long ago <code>lastSeen</code> was: under 48 hours is <code>active</code>, under 7 days is <code>stalled</code>, anything older is <code>dormant</code>.</p>
<p><code>confidence</code> is a weighted sum of four signals, each normalized to a maximum of 1 before weighting, so the total can't exceed 1 either. <code>distinctDays / 5</code>, capped at 1, contributes up to 35%, treating five or more distinct days as fully confident on that axis. <code>sessions.length / 5</code>, capped at 1, contributes up to 25%. <code>totalEvents / 20</code>, capped at 1, contributes up to 20%. And whether <code>type</code> is anything other than <code>unclassified</code> contributes the final 20% as an all-or-nothing bonus.</p>
<p>A thread revisited across five-plus days, across five-plus sessions, with twenty-plus events, that also classified cleanly, scores a full 1.0. A thread that's a single session with two events and no classification scores close to 0.</p>
<p><code>signals</code> is a plain-English audit trail for the confidence score and status: it explains why a thread looks the way it does, listing things like how many days it was revisited across, what comparison or learning language was found, the session and event counts, and how recently it was last active. The dashboard surfaces these directly.</p>
<p>Finally, <code>title</code> is a placeholder: the top three keywords from the thread's accumulated <code>keywordSet</code>, title-cased and joined with spaces, or <code>"Untitled Thread"</code> if there are none.</p>
<p>This is deliberately weak. Later in this guide, AI labeling replaces this heuristic title, along with <code>summary</code> and <code>nextStep</code>, with something grounded in what the thread is actually about (but the thread is fully usable without that step, too).</p>
<h3 id="heading-putting-it-together">Putting it Together</h3>
<p><code>buildThreads</code> ties everything in this section together:</p>
<pre><code class="language-typescript">export async function buildThreads(): Promise&lt;{ sessions: number; threads: number }&gt; {
  const sessions = await getAllSessions();

  if (sessions.length === 0) {
    await clearThreads();
    return { sessions: 0, threads: 0 };
  }

  const ambient = detectAmbientDomains(sessions);

  const builders = clusterSessions(sessions, ambient);

  const substantive = builders.filter(
    (b) =&gt; !(b.sessions.length === 1 &amp;&amp; b.sessions[0].events.length &lt; 3)
  );

  const threads = substantive.map(scoreThread);

  await clearThreads();
  await putThreads(threads);

  console.table(
    threads.map((t) =&gt; ({
      title:        t.title,
      type:         t.type,
      status:       t.status,
      confidence:   t.confidence,
      distinctDays: t.distinctDays,
      sessions:     t.sessions.length,
      events:       t.sessions.reduce((n, s) =&gt; n + s.events.length, 0),
      keywords:     [...new Set(t.sessions.flatMap((s) =&gt; s.keywords))].slice(0, 5).join(", "),
    }))
  );

  return { sessions: sessions.length, threads: threads.length };
}
</code></pre>
<p>The order here matters. <code>detectAmbientDomains</code> runs once, over every session, before any clustering happens, since ambient detection needs the full picture of your browsing to know what counts as "every day".</p>
<p><code>clusterSessions</code> then produces <code>ThreadBuilder</code>s, which get filtered before scoring: a <code>ThreadBuilder</code> with exactly one session and fewer than three events is almost always a stray page load that didn't merge with anything, so it's dropped rather than becoming a thread with a confidence near zero.</p>
<p>Everything that survives gets scored by <code>scoreThread</code>, persisted, and printed via <code>console.table</code>, which is the tuning aid mentioned earlier. If you open the service worker's console after running this, every thread is laid out in a sortable table. This is the fastest way to spot a <code>SIMILARITY_THRESHOLD</code> that's too high or too low.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>Like the previous two sections, <code>buildThreads</code> doesn't have a UI yet. It'll get wired up to a "Build intent map" button alongside the other two when you design the dashboard later in this guide.</p>
<p>For now, confirm that <code>src/pipeline/ambient.ts</code>, the updated <code>src/db/index.ts</code>, and <code>src/pipeline/threads.ts</code> all build without errors, and that <code>getDB()</code> reports version 3 the next time the extension reloads. <code>intent_threads</code> should now be listed alongside <code>raw_events</code> and <code>sessions</code> in DevTools.</p>
<p>At this point, the entire core pipeline runs end to end, locally, with no API keys involved: your browsing history becomes raw events, raw events become sessions, and sessions become scored, classified intent threads.</p>
<p>Everything from here is optional and additive: cleaning up a source of self-referential noise this pipeline doesn't yet handle (which you probably want to look at and incorporate), then AI labeling, brand grounding, and the dashboard that ties it all together.</p>
<h2 id="heading-how-to-clean-up-self-referential-noise">How to Clean Up Self-Referential Noise</h2>
<p>Run the pipeline a few times against your own browsing and a strange kind of thread starts appearing: one made entirely of openloops itself.</p>
<p>The dashboard is a web page, so every time you open it to check your threads, that page load gets captured as an event. If you're also developing the extension, your <code>localhost</code> dev server and any private-network addresses end up in the data too.</p>
<p>The tool ends up watching itself use itself, and that self-reference pollutes the intent map in two distinct ways which are worth separating.</p>
<h3 id="heading-the-two-problems">The Two Problems</h3>
<p>The first problem is the extension's own pages. A Chrome extension's dashboard loads from a <code>chrome-extension://</code> URL, and Chrome's own internal pages use <code>chrome://</code>. Left unfiltered, opening the openloops dashboard ten times in an afternoon produces ten events on a <code>chrome-extension://</code> origin, which cluster happily into a thread about, essentially, looking at your threads.</p>
<p>This is circular and useless, and because you tend to open the dashboard often while the rest of your browsing is quieter, this self-thread can score deceptively high on recency and session count.</p>
<p>The second problem is local development infrastructure. If you're building the extension, or any local project, your history fills with <code>localhost:5173</code>, <code>127.0.0.1:8080</code>, and maybe LAN addresses like <code>192.168.1.40</code>. These are real page visits as far as Chrome is concerned, but they carry no browsing intent in the sense openloops cares about. Worse, they'd later be sent to <a href="http://context.dev">context.dev</a> during brand enrichment, where they can never resolve to anything and would only waste API credits.</p>
<p>Both problems share a root cause: the pipeline is capturing URLs that aren't really part of your browsing in the first place. The fix is to define what counts as a real, external web page once, and apply that definition everywhere a URL or domain enters the system.</p>
<h3 id="heading-one-definition-applied-everywhere">One Definition, Applied Everywhere</h3>
<p>The two helpers that do this, <code>isHttpUrl</code> and <code>isLocalHost</code>, were written back when you first built <code>src/lib/util.ts</code>. We deliberately introduced them early for exactly this moment.</p>
<p><code>isHttpUrl</code> returns true only for <code>http://</code> and <code>https://</code> URLs, which excludes <code>chrome-extension://</code>, <code>chrome://</code>, <code>about:</code>, and <code>file://</code> in one stroke. <code>isLocalHost</code> returns true for <code>localhost</code>, loopback and private IP ranges, and <code>.local</code> hostnames.</p>
<p>The thing that makes them effective is consistency: the same two functions guard every entry point, so the definition of "a real page" can never drift between one part of the pipeline and another. There are three such entry points.</p>
<p>Live capture, in <code>src/background.ts</code>, calls <code>isHttpUrl</code> before recording anything:</p>
<pre><code class="language-typescript">if (!isHttpUrl(url)) return;
</code></pre>
<p>The backfill, in <code>src/pipeline/backfill.ts</code>, applies the same guard to every history item before fetching its visits:</p>
<pre><code class="language-typescript">if (!item.url) return [];
if (!isHttpUrl(item.url)) return [];
</code></pre>
<p>And the noise filter, in <code>src/pipeline/noise.ts</code>, checks both helpers at the very top of <code>isNoise</code>, before any of its domain or title rules run:</p>
<pre><code class="language-typescript">export function isNoise(event: RawEvent): boolean {
  if (!isHttpUrl(event.url)) return true;
  if (isLocalHost(event.domain)) return true;
  return domainIsBlocked(event.domain) || titleIsGeneric(event.title, event.domain);
}
</code></pre>
<p>Capture and backfill already screen out non-web URLs, so checking <code>isHttpUrl</code> a third time inside <code>isNoise</code> looks redundant, and in normal operation it is. The third check is a guarantee: if a stray non-web event ever reaches <code>raw_events</code> through some path you didn't anticipate (like a future capture mechanism, imported data, or a bug), it still can't survive into a session.</p>
<p>Each stage defends its own input rather than trusting that an earlier stage did its job. This is what keeps a single missed case from silently propagating all the way into the intent map.</p>
<h3 id="heading-defending-the-enrichment-boundary-too">Defending the Enrichment Boundary Too</h3>
<p>The same <code>isLocalHost</code> check appears once more, in the brand enrichment step you'll build next, where domains get sent to <a href="http://context.dev">context.dev</a>. Even though <code>isNoise</code> already strips local addresses before sessionization, the enrichment function filters them again before making any network call:</p>
<pre><code class="language-typescript">const unique = [...new Set(domains)].filter((d) =&gt; !isLocalHost(d));
</code></pre>
<p>The reasoning is the same defense-in-depth idea, applied to a boundary where the cost of a mistake is higher. A local address that somehow reached a thread's domain list shouldn't just be useless noise in the UI. It should never leave your machine as part of an API request. Putting the filter directly at the network boundary means that guarantee holds regardless of what happened upstream.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>After loading the updated build, openloops should stop appearing in its own intent map. To verify, open the dashboard a handful of times, browse some real pages, then rebuild the pipeline: the <code>chrome-extension://</code> self-thread should be gone, and no <code>localhost</code> or private-IP domains should appear in any thread's domain list.</p>
<p>If you inspect <code>raw_events</code> in DevTools, you may still see live-captured events from before this fix, since the backfill clears and rewrites events but live capture appends. Running a fresh "Scan my history" wipes and repopulates <code>raw_events</code> cleanly under the new rules.</p>
<p>With the pipeline now producing a clean intent map of genuinely external browsing, it's worth making those threads more legible.</p>
<p>Up to now, each thread's title is just its top three keywords stitched together, and there's no summary or suggested next step at all. The next section adds the first optional, key-gated layer: AI labeling with Claude.</p>
<h2 id="heading-how-to-label-threads-with-claude">How to Label Threads with Claude</h2>
<p>A thread titled "Typescript Generics Handbook" is readable, but it's a description of the keywords – not of what you were trying to do. "Learning TypeScript's advanced type system" is the kind of label a person would actually write, and the difference between those two is the gap this section closes.</p>
<p>Claude reads each thread's keywords, domains, and sample page titles, and returns a real title, a one-sentence summary, a classification, and a concrete next step.</p>
<p>This is the first part of openloops that calls an external API and requires a key. Everything about its design is shaped by one constraint: the request has to survive real data, where a person might have thirty or forty threads, each carrying a dozen page titles.</p>
<p>The naïve version of this is to send all the threads in one request and ask for all the labels back. And that's exactly what the first implementation did. But it failed in a way worth walking through, because the fix is the most instructive part of the whole section.</p>
<h3 id="heading-storing-keys-locally">Storing Keys Locally</h3>
<p>Before any API call, the key needs somewhere to live. openloops keeps it in <code>chrome.storage.local</code>, which never syncs anywhere and never leaves the device. Create <code>src/lib/settings.ts</code>:</p>
<pre><code class="language-typescript">export async function getApiKey(): Promise&lt;string | null&gt; {
  const result = await chrome.storage.local.get("anthropicApiKey");
  return (result.anthropicApiKey as string) ?? null;
}

export async function setApiKey(key: string): Promise&lt;void&gt; {
  await chrome.storage.local.set({ anthropicApiKey: key });
}
</code></pre>
<p>The same file later grows parallel getters and setters for the <a href="http://context.dev">context.dev</a> key and the assistant's model and effort preferences, all following this identical shape. So it's enough to understand this one pair to understand all of them.</p>
<h3 id="heading-the-first-version-and-how-it-broke">The First Version, and How it Broke</h3>
<p>The first labeling implementation sent every thread to Claude in a single request: serialize all forty threads into one JSON payload, ask for a JSON array of forty labels in return, parse it, write it back. It worked perfectly with five or six threads during early testing, then silently produced nothing once a real history with thirty-plus threads went through it. There was no error or thrown exception, just threads that kept their old keyword titles as if the labeling had never run.</p>
<p>The cause was output token truncation. A request specifies <code>max_tokens</code>, the ceiling on how much the model may generate in response, and forty threads' worth of titles, summaries, and next steps is a lot of output. When the response hit that ceiling mid-generation, the JSON array was cut off partway through an opening <code>[</code> and thirty complete objects followed by half of the thirty-first and no closing <code>]</code>. <code>JSON.parse</code> on that throws, the catch block logged it and returned nothing, and because labeling was designed to fail gracefully and leave existing titles intact, the failure was invisible from the UI.</p>
<p>Two design changes came out of this, and both are in the final code: split the work into small batches so no single response can grow large enough to truncate, and make the parsing resilient enough that one bad batch can't take down the whole run.</p>
<h3 id="heading-batching-the-requests">Batching the Requests</h3>
<p>Create <code>src/pipeline/label.ts</code>, starting with the per-batch request function:</p>
<pre><code class="language-typescript">import { getAllThreads, putThreads, getAllBrands } from "../db/index";
import type { IntentThread } from "../types";

interface ThreadDescriptor {
  id: string;
  keywords: string[];
  domains: string[];
  sampleTitles: string[];
  domainContext: string[];
}

interface LabelResult {
  id: string;
  title: string;
  summary: string;
  type: string;
  nextStep: string;
}

const VALID_TYPES: ReadonlySet&lt;IntentThread["type"]&gt; = new Set([
  "buying",
  "research",
  "learning",
  "planning",
  "unclassified",
]);

const BATCH_SIZE = 10;
const MAX_TOKENS_PER_BATCH = 4000;

async function callClaudeBatch(
  apiKey: string,
  systemPrompt: string,
  batch: ThreadDescriptor[],
): Promise&lt;LabelResult[] | null&gt; {
  const response = await fetch("https://api.anthropic.com/v1/messages", {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-api-key": apiKey,
      "anthropic-version": "2023-06-01",
      "anthropic-dangerous-direct-browser-access": "true",
    },
    body: JSON.stringify({
      model: "claude-haiku-4-5-20251001",
      max_tokens: MAX_TOKENS_PER_BATCH,
      system: systemPrompt,
      messages: [
        {
          role: "user",
          content: JSON.stringify(batch),
        },
      ],
    }),
  });

  if (!response.ok) {
    let body = "";
    try { body = (await response.text()).slice(0, 400); } catch { }
    console.error(
      `[openloops] label: API request failed\n` +
      `  → HTTP \({response.status} \){response.statusText}\n` +
      `  body: ${body || "(empty)"}`,
    );
    if (response.status === 401) {
      throw new Error("Invalid API key. Check your Anthropic API key and try again.");
    }
    throw new Error(`API request failed: \({response.status} \){response.statusText}`);
  }

  const data = await response.json();
  const raw: string = data.content[0].text;

  const cleaned = raw
    .trim()
    .replace(/^```(?:json)?\s*/, "")
    .replace(/```\s*$/, "")
    .trim();

  try {
    return JSON.parse(cleaned);
  } catch (err) {
    console.error(`[openloops] label: parse error: ${err instanceof Error ? err.message : String(err)}`);
    console.error(`[openloops] label: raw tail (last 400 chars):\n${raw.slice(-400)}`);
    return null;
  }
}
</code></pre>
<p><code>BATCH_SIZE</code> of 10 with <code>MAX_TOKENS_PER_BATCH</code> of 4000 is the direct answer to the truncation problem. Ten threads' worth of labels comfortably fits inside 4000 output tokens with room to spare, so a batch can't hit the ceiling and get cut off. A history with forty threads becomes four independent requests rather than one oversized one.</p>
<p>The request itself uses raw <code>fetch</code> rather than Anthropic's TypeScript SDK, because the SDK isn't built to run in a browser or extension context.</p>
<p>Browser-originated calls to the Anthropic API also require the <code>anthropic-dangerous-direct-browser-access</code> header, which is what opts into this usage pattern. The model is Claude Haiku, the fastest and cheapest in the lineup, which is well-matched to a high-volume, structured-output task like this one where you're making several calls and want them quick.</p>
<p>The error handling splits into two deliberately different behaviors. An HTTP-level failure (a 401 from a bad key, a 429 from rate limiting) throws, because every subsequent batch would fail the same way and there's no point continuing. A <em>parse</em> failure, by contrast, returns <code>null</code> rather than throwing, so the caller can skip just that one batch and keep going with the rest.</p>
<p>The fence-stripping before <code>JSON.parse</code> handles a common real-world wrinkle: models sometimes wrap JSON output in a Markdown code fence (<code>```json</code>), even when asked for raw JSON. The two <code>.replace</code> calls strip a leading fence and a trailing fence if present, tolerating surrounding whitespace, so a response comes through whether or not it arrived wrapped.</p>
<p>When parsing still fails, the catch logs the last 400 characters of the raw response, which is precisely where you'd see the truncation signature of a cut-off array, the diagnostic that would have made the original bug obvious in minutes.</p>
<h3 id="heading-building-the-prompt-and-merging-results">Building the Prompt and Merging Results</h3>
<p>The public <code>labelThreads</code> function builds the descriptors, runs the batches, and merges what comes back:</p>
<pre><code class="language-typescript">export async function labelThreads(apiKey: string): Promise&lt;{ labeled: number }&gt; {
  const threads = await getAllThreads();
  if (threads.length === 0) return { labeled: 0 };

  const allBrands = await getAllBrands();
  const brandMap = new Map(allBrands.map((b) =&gt; [b.domain, b]));

  const descriptors: ThreadDescriptor[] = threads.map((t) =&gt; {
    const keywords = [...new Set(t.sessions.flatMap((s) =&gt; s.keywords))].slice(0, 8);
    const domains  = [...new Set(t.sessions.flatMap((s) =&gt; s.domains))].slice(0, 5);
    const titles   = [...new Set(t.sessions.flatMap((s) =&gt; s.events.map((e) =&gt; e.title)))].slice(0, 20);

    const domainContext = domains
      .map((d) =&gt; {
        const brand = brandMap.get(d);
        if (!brand || !brand.name) return null;
        let line = `\({d}: \){brand.name}`;
        if (brand.description) line += ` — ${brand.description}`;
        if (brand.industry)    line += ` (${brand.industry})`;
        return line;
      })
      .filter((s): s is string =&gt; s !== null);

    return { id: t.id, keywords, domains, sampleTitles: titles, domainContext };
  });

  const systemPrompt = `You label browsing intent threads. Return ONLY a JSON array — no markdown fences, no explanation.
Each element: { "id": "&lt;thread id&gt;", "title": "&lt;3-6 word title&gt;", "summary": "&lt;1 sentence&gt;", "type": "&lt;buying|research|learning|planning|unclassified&gt;", "nextStep": "&lt;one concrete, specific action to move this thread forward or close the loop&gt;" }
The nextStep must be grounded in what the person was actually looking at. Be specific — name the actual decision, comparison, or action (e.g. "Decide between MacBook Pro and Dell XPS — your open question was battery life") rather than generic advice ("continue researching"). Use the sampleTitles and domainContext to ground it.
Each thread descriptor may include a "domainContext" array of company descriptions for the sites visited. When present, use these to produce sharper, more specific titles, summaries, and next steps grounded in what each company actually does.
Respond with exactly one array covering every thread in the request.`;

  const allResults: LabelResult[] = [];
  let failedBatches = 0;
  for (let i = 0; i &lt; descriptors.length; i += BATCH_SIZE) {
    const batch = descriptors.slice(i, i + BATCH_SIZE);
    const results = await callClaudeBatch(apiKey, systemPrompt, batch);
    if (results === null) {
      failedBatches++;
      continue;
    }
    allResults.push(...results);
  }

  const byId = new Map(allResults.map((r) =&gt; [r.id, r]));

  let labeled = 0;
  const updated = threads.map((t) =&gt; {
    const label = byId.get(t.id);
    if (!label) return t;

    const type = VALID_TYPES.has(label.type as IntentThread["type"])
      ? (label.type as IntentThread["type"])
      : t.type;

    labeled++;
    return {
      ...t,
      title:    label.title    || t.title,
      summary:  label.summary  || undefined,
      nextStep: label.nextStep || undefined,
      type,
    };
  });

  await putThreads(updated);
  return { labeled };
}
</code></pre>
<p>Each thread is compressed into a <code>ThreadDescriptor</code> carrying only what Claude needs to label it: up to eight keywords, five domains, and twenty sample page titles, capped so a thread with hundreds of events doesn't bloat the payload.</p>
<p>The <code>domainContext</code> field is the hook for the brand-grounding step covered in the next section. It's empty for now since no brands have been fetched yet, which is exactly why labeling works fine on its own and gets sharper once grounding is added.</p>
<p>The merge step is where a failed batch costs you only its own threads. Results come back as a flat list across all successful batches, indexed by thread id into <code>byId</code>.</p>
<p>Then every thread is walked: if a label came back for it, the AI title, summary, next step, and type are merged in, with the returned <code>type</code> validated against <code>VALID_TYPES</code> and falling back to the heuristic type if the model returned something unexpected. If no label came back, because that thread's batch failed to parse, the thread is returned untouched, keeping the keyword title and heuristic classification it already had.</p>
<p>A single failed batch costs you ten threads' worth of polish, not the entire run, and never corrupts a thread with malformed data.</p>
<p>Notice that <code>title</code>, <code>summary</code>, and <code>nextStep</code> all guard against empty strings with <code>|| t.title</code> and <code>|| undefined</code>. A thread always has a usable title even if the model returned a blank one, and <code>summary</code> and <code>nextStep</code> stay <code>undefined</code> rather than becoming empty strings. This keeps the dashboard's "does this thread have a summary?" checks honest.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>Labeling needs a key and a button, both of which arrive with the dashboard later in this guide, so a full end-to-end test waits until then.</p>
<p>What you can verify now is that <code>src/lib/settings.ts</code> and <code>src/pipeline/label.ts</code> compile, and that the request shape is correct by calling <code>labelThreads</code> with a real key from a temporary test harness if you want immediate feedback. When it runs against built threads, the <code>console</code> will show batch progress, and your threads' titles in IndexedDB will change from keyword fragments to readable phrases, with <code>summary</code> and <code>nextStep</code> fields appearing for the first time.</p>
<p>The labels are already a large improvement, but they're working from keywords and bare domain names. This means a thread built around <code>mastra.ai</code> and <code>langchain.com</code> has no idea those are AI agent frameworks. It only sees two domain strings.</p>
<p>The next section closes that gap by resolving domains into real company descriptions before labeling. This is the grounding step that gives the AI something concrete to reason about.</p>
<h2 id="heading-how-to-ground-labels-with-contextdev">How to Ground Labels with <a href="http://context.dev">context.dev</a></h2>
<p>This is the most distinctive idea in openloops, so it's worth stating plainly before any code: instead of asking the model to label a thread from keywords and bare domain names, openloops first resolves each domain into a real company description – what the company is, what industry it's in, what it actually does – and feeds those descriptions into the labeling prompt. The model labels the thread knowing that <code>mastra.ai</code> and <code>langchain.com</code> are both AI agent frameworks, rather than seeing two opaque strings it has to guess about.</p>
<p>A thread whose keywords are "mastra langchain sholajegede" produces, ungrounded, a title like "Mastra Langchain Sholajegede", a literal echo of the keywords. Grounded with the knowledge that those domains are competing agent frameworks, the same thread becomes "Benchmarking Mastra against LangChain", a title that names the actual intent.</p>
<p>The raw material for a good label was always there in the browsing. What was missing was the context to interpret it, and that context is exactly what a brand-intelligence API provides.</p>
<h3 id="heading-what-the-api-returns">What the API Returns</h3>
<p>openloops uses context.dev, which resolves a domain into a structured brand record: company name, a one-line description, industry classification, brand colors, and logo URLs. The grounding step needs the name, description, and industry, while the logo and colors get used later by the dashboard to render domain chips.</p>
<p>This step is entirely optional: the labeling from the previous section works without it, and grounding simply makes the output sharper when a context.dev key is present.</p>
<p>Like the Anthropic key, the context.dev key lives in <code>chrome.storage.local</code>, via the same getter/setter pattern in <code>src/lib/settings.ts</code>:</p>
<pre><code class="language-typescript">export async function getContextKey(): Promise&lt;string | null&gt; {
  const result = await chrome.storage.local.get("contextDevApiKey");
  return (result.contextDevApiKey as string) ?? null;
}

export async function setContextKey(key: string): Promise&lt;void&gt; {
  await chrome.storage.local.set({ contextDevApiKey: key });
}
</code></pre>
<p>Brand records also need a place to be cached, since resolving the same domain twice is wasteful and costs API credits. Bump <code>DB_VERSION</code> to 4 and add a <code>domain_brands</code> store keyed by domain:</p>
<pre><code class="language-typescript">import type { RawEvent, Session, IntentThread, Brand } from "../types";

interface OpenloopsDB extends DBSchema {
  raw_events: { key: string; value: RawEvent; indexes: { by_visitedAt: number } };
  sessions: { key: string; value: Session; indexes: { by_startedAt: number } };
  intent_threads: { key: string; value: IntentThread; indexes: { by_lastSeen: number } };
  domain_brands: {
    key: string;
    value: Brand;
  };
}

const DB_VERSION = 4;
</code></pre>
<p>Inside the <code>upgrade</code> callback, the new store is added with the same guard as the others, and <code>domain_brands</code> is keyed on <code>domain</code> rather than <code>id</code> because a domain is its own natural unique key:</p>
<pre><code class="language-typescript">if (!db.objectStoreNames.contains("domain_brands")) {
  db.createObjectStore("domain_brands", { keyPath: "domain" });
}
</code></pre>
<p>The matching helpers add one that's specific to caching, <code>getCachedDomains</code>. This returns the set of domains already resolved so the enrichment step can skip them:</p>
<pre><code class="language-typescript">export async function getBrand(domain: string): Promise&lt;Brand | undefined&gt; {
  const db = await getDB();
  return db.get("domain_brands", domain);
}

export async function putBrands(brands: Brand[]): Promise&lt;void&gt; {
  if (brands.length === 0) return;
  const db = await getDB();
  const tx = db.transaction("domain_brands", "readwrite");
  await Promise.all([...brands.map((b) =&gt; tx.store.put(b)), tx.done]);
}

export async function getAllBrands(): Promise&lt;Brand[]&gt; {
  const db = await getDB();
  return db.getAll("domain_brands");
}

export async function getCachedDomains(): Promise&lt;Set&lt;string&gt;&gt; {
  const db = await getDB();
  const keys = await db.getAllKeys("domain_brands");
  return new Set(keys);
}
</code></pre>
<h3 id="heading-fetching-one-brand">Fetching One Brand</h3>
<p>Create <code>src/pipeline/enrich.ts</code>. The core is a function that resolves a single domain, and most of its length is there to make sure a slow or failing lookup can never hang or crash the whole step:</p>
<pre><code class="language-typescript">import { getCachedDomains, putBrands } from "../db/index";
import { isLocalHost } from "../lib/util";
import type { Brand } from "../types";

const API_BASE        = "https://api.context.dev/v1";
const LOGO_LINK_BASE  = "https://logos.context.dev";

const REQUEST_TIMEOUT_MS = 15_000;
const BATCH_SIZE     = 3;
const BATCH_DELAY_MS = 2_000;

interface FetchResult {
  brand: Brand | null;
  errorCode?: string;
}

async function fetchBrand(domain: string, contextKey: string): Promise&lt;FetchResult&gt; {
  const url = `\({API_BASE}/brand/retrieve?domain=\){encodeURIComponent(domain)}`;
  const headers = { Authorization: `Bearer ${contextKey}` };

  async function attempt(): Promise&lt;Response&gt; {
    const ctrl = new AbortController();
    const tid  = setTimeout(() =&gt; ctrl.abort(), REQUEST_TIMEOUT_MS);
    try {
      return await fetch(url, { headers, signal: ctrl.signal });
    } finally {
      clearTimeout(tid);
    }
  }

  try {
    let res = await attempt();

    if (res.status === 408) {
      res = await attempt();
    }

    if (!res.ok) {
      let body = "";
      try { body = (await res.text()).slice(0, 400); } catch { }
      console.error(`[openloops] enrich: HTTP \({res.status} for "\){domain}" — ${body}`);
      return { brand: null, errorCode: String(res.status) };
    }

    let data: { status?: string; brand?: Record&lt;string, unknown&gt; };
    try {
      data = await res.json();
    } catch (e) {
      return { brand: null, errorCode: "parse" };
    }

    if (data.status !== "ok" || !data.brand) {
      return { brand: null, errorCode: "shape" };
    }

    const b = data.brand as {
      title?:        string;
      description?:  string;
      colors?:       { hex?: string }[];
      logos?:        { url?: string }[];
      industries?:   { eic?: { industry?: string; subindustry?: string }[] };
    };

    const logoUrl =
      b.logos?.[0]?.url ||
      `\({LOGO_LINK_BASE}?domain=\){encodeURIComponent(domain)}`;

    return {
      brand: {
        domain,
        name:        b.title                          ?? domain,
        description: b.description                    ?? "",
        industry:    b.industries?.eic?.[0]?.industry ?? "",
        logoUrl,
        brandColor:  b.colors?.[0]?.hex               ?? "",
      },
    };

  } catch (err) {
    if (err instanceof Error &amp;&amp; err.name === "AbortError") {
      return { brand: null, errorCode: "timeout" };
    }
    return { brand: null, errorCode: "network" };
  }
}
</code></pre>
<p>The request authenticates with a bearer token and hits a single <code>brand/retrieve</code> endpoint. The <code>attempt</code> inner function wraps each call in an <code>AbortController</code> with a 15-second timeout, so a stalled connection aborts itself rather than hanging the enrichment step indefinitely.</p>
<p>The <code>finally</code> clears the timer whether the request succeeds, fails, or aborts. A <code>408</code> response from context.dev means a cold cache miss on their side, which their documentation says to retry once, so a single retry handles it before giving up.</p>
<p>The response is unpacked defensively at every level: a non-OK status returns a <code>FetchResult</code> with the HTTP code, a body that won't parse returns a <code>"parse"</code> error, and a response whose shape isn't what's expected returns a <code>"shape"</code> error.</p>
<p>When the brand record does come through, each field falls back to a sensible default if absent, the company name falls back to the domain itself, the description and industry to empty strings, and the logo to context.dev's keyless logo CDN if the record carries no logo URL.</p>
<p>Every failure path returns <code>{ brand: null, errorCode }</code> rather than throwing, which is what lets the batch driver above it treat a single domain's failure as a skip rather than a crash.</p>
<h3 id="heading-enriching-domains-in-batches">Enriching Domains in Batches</h3>
<p>The public <code>enrichDomains</code> function resolves a list of domains, skipping ones already cached and respecting the API's rate limit:</p>
<pre><code class="language-typescript">export async function enrichDomains(
  contextKey: string,
  domains: string[],
): Promise&lt;{ enriched: number; failed: number; error?: string }&gt; {
  const unique = [...new Set(domains)].filter((d) =&gt; !isLocalHost(d));

  let cached: Set&lt;string&gt;;
  try {
    cached = await getCachedDomains();
  } catch (err) {
    return { enriched: 0, failed: 0, error: "DB error" };
  }

  const toFetch = unique.filter((d) =&gt; !cached.has(d));
  if (toFetch.length === 0) return { enriched: 0, failed: 0 };

  let enriched = 0;
  let failed   = 0;
  let firstErrorCode: string | undefined;

  for (let i = 0; i &lt; toFetch.length; i += BATCH_SIZE) {
    const batch   = toFetch.slice(i, i + BATCH_SIZE);
    const results = await Promise.all(batch.map((d) =&gt; fetchBrand(d, contextKey)));

    const brands = results.map((r) =&gt; r.brand).filter((b): b is Brand =&gt; b !== null);

    for (const r of results) {
      if (!r.brand) {
        failed += 1;
        if (!firstErrorCode) firstErrorCode = r.errorCode;
      }
    }

    if (brands.length &gt; 0) {
      try {
        await putBrands(brands);
        enriched += brands.length;
      } catch (err) {
        failed += brands.length;
      }
    }

    if (i + BATCH_SIZE &lt; toFetch.length) {
      await new Promise&lt;void&gt;((resolve) =&gt; setTimeout(resolve, BATCH_DELAY_MS));
    }
  }

  let error: string | undefined;
  if (firstErrorCode) {
    const map: Record&lt;string, string&gt; = {
      "401":     "401 — invalid key",
      "403":     "403 — check key permissions",
      "429":     "429 — rate limited, try again later",
      "timeout": "request timeout (15 s)",
      "network": "unreachable — check network/CORS",
    };
    error = map[firstErrorCode] ?? firstErrorCode;
  }

  return { enriched, failed, error };
}
</code></pre>
<p>The function opens by stripping local addresses with <code>isLocalHost</code>, the enrichment-boundary guard discussed in the self-referential noise section. This means that a dev server can never be sent to context.dev even if it slipped into a thread's domain list. It then removes already-cached domains via <code>getCachedDomains</code>, so re-running enrichment only ever fetches domains it hasn't seen. This keeps credit usage proportional to new browsing rather than total browsing.</p>
<p>The remaining domains are fetched three at a time, with a two-second pause between batches. This keeps the request rate well under the API's limit without making the user wait through a long serial queue.</p>
<p>Failures are tallied rather than thrown: a domain that fails to resolve increments <code>failed</code> and records its error code, but the loop carries on. The first error code encountered gets mapped to a human-readable message at the end so the UI can show something useful, such as an invalid-key or rate-limit notice.</p>
<p>The whole function returns counts rather than raising, which matters because the dashboard runs enrichment immediately before labeling, and a problem fetching brands should never prevent the labeling that follows it.</p>
<h3 id="heading-how-grounding-feeds-back-into-labeling">How Grounding Feeds Back into Labeling</h3>
<p>Grounding connects back to <code>labelThreads</code> from the previous section, which already builds a <code>domainContext</code> array for each thread by looking up every domain in the brand cache:</p>
<pre><code class="language-typescript">const domainContext = domains
  .map((d) =&gt; {
    const brand = brandMap.get(d);
    if (!brand || !brand.name) return null;
    let line = `\({d}: \){brand.name}`;
    if (brand.description) line += ` — ${brand.description}`;
    if (brand.industry)    line += ` (${brand.industry})`;
    return line;
  })
  .filter((s): s is string =&gt; s !== null);
</code></pre>
<p>Before enrichment runs, the brand cache is empty, every lookup returns nothing, <code>domainContext</code> is an empty array, and the prompt falls back to keywords and domain names alone.</p>
<p>After enrichment, the same code produces lines like <code>mastra.ai: Mastra — TypeScript framework for building AI agents (Developer Tools)</code>, and the labeling prompt's instruction to use <code>domainContext</code> "to produce sharper, more specific titles, summaries, and next steps" finally has something to work with.</p>
<p>The two steps are decoupled by design: labeling never requires grounding, but grounding measurably improves labeling. This is why the dashboard runs them in sequence as a single "enrich, then label" action.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>Like the labeling step, enrichment is exercised through the dashboard, so the full path waits for the dashboard section. For now, confirm that <code>src/pipeline/enrich.ts</code> and the updated <code>src/db/index.ts</code> compile, and that <code>getDB()</code> reports version 4 with <code>domain_brands</code> present in DevTools.</p>
<p>Once it runs against real threads with a context.dev key, the <code>domain_brands</code> store fills with cached records, and your thread labels should noticeably sharpen. The clearest single demonstration will be any thread built around niche or technical domains whose names don't, on their own, reveal what they are.</p>
<p>Every piece of the engine now exists: capture, sessions, clustering, scoring, labeling, and grounding. What's missing is the surface that drives them and shows the results.</p>
<p>The next section builds the dashboard, the three-column React interface with its onboarding flow and pipeline state machine, that turns this pipeline into something a person actually uses.</p>
<h2 id="heading-how-to-design-the-dashboard">How to Design the Dashboard</h2>
<p>The dashboard is a single React component tree rendered into the full-tab page you wired up at the very start when you set <code>options_page</code> in the manifest.</p>
<p>It does three jobs: it drives the pipeline (the buttons that run scanning, session-building, thread-building, and labeling), it displays the resulting intent map (threads grouped by status), and it hosts the assistant covered in the next section.</p>
<p>This section focuses on the structure and the one piece of genuinely interesting logic: the state machine that decides which pipeline button is live at any moment. We'll treat the styling at a summary level here, since it's mostly conventional CSS.</p>
<h3 id="heading-the-three-column-layout">The Three-Column Layout</h3>
<p><code>src/dashboard/App.tsx</code> lays out three columns inside a flex shell. The left rail holds the pipeline controls, the API-key inputs, and the status filter. The center column is the main content: either the onboarding welcome screen or the intent map of threads. The right column holds overview statistics and the assistant chat.</p>
<pre><code class="language-plaintext">┌──────────────┬───────────────────────────┬──────────────────┐
│  LEFT RAIL   │       MAIN COLUMN         │  RIGHT COLUMN    │
│              │                           │                  │
│  Pipeline    │  Welcome screen           │  Overview stats  │
│   · Scan     │    — or —                 │                  │
│   · Sessions │  Intent map:              │  Assistant chat  │
│   · Threads  │   ACTIVE   threads        │   · messages     │
│              │   STALLED  threads        │   · composer     │
│  Keys        │   DORMANT  threads        │   · model/effort │
│  Filter      │                           │                  │
└──────────────┴───────────────────────────┴──────────────────┘
</code></pre>
<p>Each thread renders as a card showing its title, type and status pills, the AI summary, the next-step row with a Resume button, a confidence bar, and a collapsible details section with domains, keywords, and signals.</p>
<p>The cards are grouped into ACTIVE, STALLED, and DORMANT sections, sorted by confidence within each group. The threads most worth acting on rise to the top of the most urgent group.</p>
<p>The styling lives in <code>src/dashboard/app.css</code> and is conventional: a dark theme defined through CSS custom properties (a near-black background, a single orange accent at <code>--accent: #ff5c33</code>, a small scale of grays for text and borders), a monospace font for labels and metadata, and a sans-serif for content.</p>
<p>The design choices that matter for usability are the status-based color coding (the accent for active, a muted amber for stalled, gray for dormant) and the confidence bar's width mapping directly to the thread's confidence score.</p>
<p>None of the CSS is load-bearing for understanding the build, so rather than reproduce it, the rest of this section focuses on the logic the styling sits on top of.</p>
<h3 id="heading-the-pipeline-state-machine">The Pipeline State Machine</h3>
<p>The pipeline has a strict order: you can't build sessions before scanning history, and you can't build threads before building sessions. The dashboard encodes this as a small state machine, and getting it right is what makes the interface feel guided rather than confusing. Every button is either disabled (its input doesn't exist yet), highlighted as the next action to take, or done (re-runnable, but no longer the obvious next step).</p>
<pre><code class="language-typescript">type PipelineState = "disabled" | "next" | "done";

function pipelineStates(
  hasScanned: boolean,
  eventCount: number | null,
  sessionCount: number | null,
  threadCount: number | null,
): { scan: PipelineState; sessions: PipelineState; threads: PipelineState } {
  const hasEvents   = (eventCount   ?? 0) &gt; 0;
  const hasSessions = (sessionCount ?? 0) &gt; 0;
  const hasThreads  = (threadCount  ?? 0) &gt; 0;

  if (!hasScanned)  return { scan: "next", sessions: "disabled", threads: "disabled" };
  if (!hasSessions) return { scan: "done", sessions: hasEvents ? "next" : "disabled", threads: "disabled" };
  if (!hasThreads)  return { scan: "done", sessions: "done", threads: "next" };
  return { scan: "done", sessions: "done", threads: "done" };
}
</code></pre>
<p>The function reads the presence of data at each stage and returns the state of all three buttons. Before any scan, only Scan is live, marked <code>next</code>, while the other two are disabled.</p>
<p>Once events exist but sessions don't, Scan flips to <code>done</code> and Sessions becomes <code>next</code>. Once sessions exist but threads don't, Threads becomes <code>next</code>. Once all three stages have produced output, everything is <code>done</code>, every step re-runnable but none demanding attention. The cascade walks the pipeline in order and lights up exactly one <code>next</code> action at a time, which is what turns a row of three buttons into a guided sequence.</p>
<p>The first parameter, <code>hasScanned</code>, is more subtle than a simple count. It's where a piece of plumbing from the very first capture section pays off.</p>
<p>The check can't just be "are there any events," because live capture starts populating <code>raw_events</code> the moment the extension is installed. There would <em>always</em> be events, and the onboarding would skip straight past the Scan step before the user had ever scanned.</p>
<p>The fix is the <code>source</code> field on every <code>RawEvent</code>, set to <code>"backfill"</code> or <code>"live"</code> back when you built capture. <code>hasScanned</code> comes from a dedicated query that checks specifically for backfill events:</p>
<pre><code class="language-typescript">export async function hasBackfillEvents(): Promise&lt;boolean&gt; {
  const db = await getDB();
  let cursor = await db.transaction("raw_events", "readonly").store.openCursor();
  while (cursor) {
    if (cursor.value.source === "backfill") return true;
    cursor = await cursor.continue();
  }
  return false;
}
</code></pre>
<p>This walks <code>raw_events</code> until it finds a single event with <code>source === "backfill"</code>, returning early the moment it does. Live-captured events alone never satisfy it, so "Scan my history" stays lit as the first step until the user actually runs a backfill, which is the correct onboarding behavior. The seemingly minor decision to tag each event with its origin, made several sections ago, is what makes this distinction possible now.</p>
<h3 id="heading-driving-the-welcome-screen-from-the-same-machine">Driving the Welcome Screen from the Same Machine</h3>
<p>A first-time user with no threads sees a centered welcome screen instead of an empty intent map. But rather than give that screen its own separate logic, the dashboard drives it from the same <code>pipelineStates</code> output. Whichever step is currently <code>next</code> determines which single call-to-action the welcome screen shows:</p>
<pre><code class="language-typescript">let welcomeStep: 1 | 2 | 3 = 1;
let welcomeCtaLabel = "Scan my history";
let welcomeCtaClick = handleScan;
if (scanState === "next") {
  welcomeStep = 1;
  welcomeCtaLabel = scanning ? "Scanning…" : "Scan my history";
  welcomeCtaClick = handleScan;
} else if (sessionsState === "next") {
  welcomeStep = 2;
  welcomeCtaLabel = buildingSessions ? "Building…" : "Build sessions";
  welcomeCtaClick = handleBuildSessions;
} else if (threadsState === "next") {
  welcomeStep = 3;
  welcomeCtaLabel = buildingThreads ? "Building…" : "Build your intent map";
  welcomeCtaClick = handleBuildThreads;
}
</code></pre>
<p>The welcome screen's single button always mirrors the rail's <code>next</code> action, so a user can move through scan, build sessions, and build threads by clicking one prominent button three times. The moment threads exist, the welcome screen is replaced by the intent map. The rail and the welcome screen never disagree about what to do next, because both read from the same source of truth.</p>
<h3 id="heading-wiring-the-handlers">Wiring the Handlers</h3>
<p>The handlers themselves are thin: each runs a pipeline stage, then refreshes the component's view of the database. The action that runs grounding and labeling together is the one worth seeing, because it puts into practice the decoupling described in the previous two sections:</p>
<pre><code class="language-typescript">async function handleEnrichAndLabel() {
  setLabelError(null);
  setEnrichError(null);

  if (contextKey.trim() &amp;&amp; contextKeySaved) {
    setEnriching(true);
    try {
      const allDomains = [...new Set(
        threads.flatMap((t) =&gt; t.sessions.flatMap((s) =&gt; s.domains))
      )];
      const result = await enrichDomains(contextKey.trim(), allDomains);
      if (result.error) setEnrichError(`context.dev: ${result.error}`);
      if (result.enriched &gt; 0) {
        const all = await getAllBrands();
        setBrands(new Map(all.map((b) =&gt; [b.domain, b])));
      }
    } catch (err) {
      setEnrichError(`context.dev: ${err instanceof Error ? err.message : "unknown error"}`);
    } finally {
      setEnriching(false);
    }
  }

  setLabeling(true);
  try {
    await labelThreads(apiKey.trim());
    setThreads(await getAllThreads());
  } catch (err) {
    setLabelError(err instanceof Error ? err.message : "Labeling failed.");
  } finally {
    setLabeling(false);
  }
}
</code></pre>
<p>Enrichment runs only if a context.dev key is present, and it's wrapped so that any failure (like a network error, a bad key, or a rate limit) sets an error message but never stops execution. Labeling then runs unconditionally afterward, outside the enrichment block, so it proceeds whether enrichment succeeded, failed, or was skipped entirely for lack of a key.</p>
<p>That structure is the decoupling from the grounding section made concrete: grounding improves labeling when it works, and labeling degrades gracefully to keyword-and-domain context when it doesn't.</p>
<p>The enrichment error surfaces in amber rather than red, because it's a warning (labeling still happened) rather than a blocking failure. This is a small UI cue that matches the actual severity of what went wrong.</p>
<h3 id="heading-the-resume-button">The Resume Button</h3>
<p>One interaction ties the intent map back to live browsing. Each thread card has a Resume button that reopens the pages you were on, so acting on a thread is one click rather than a hunt through history:</p>
<pre><code class="language-typescript">const RESUME_SKIP_DOMAINS = new Set([
  "google.com", "youtube.com", "bing.com", "duckduckgo.com",
  "gmail.com", "mail.google.com",
]);

function resumeThread(thread: IntentThread): void {
  const seen = new Set&lt;string&gt;();
  const urls: string[] = [];

  const sorted = thread.sessions
    .flatMap((s) =&gt; s.events)
    .sort((a, b) =&gt; b.visitedAt - a.visitedAt);

  for (const ev of sorted) {
    if (RESUME_SKIP_DOMAINS.has(ev.domain)) continue;
    if (seen.has(ev.url)) continue;
    seen.add(ev.url);
    urls.push(ev.url);
    if (urls.length &gt;= 3) break;
  }

  urls.forEach((url, i) =&gt; {
    chrome.tabs.create({ url, active: i === 0 });
  });
}
</code></pre>
<p>Resume sorts the thread's events newest-first, skips search engines and webmail (which are waypoints rather than destinations you'd want to return to), dedupes by URL, and opens the three most recent meaningful pages. The first is the active tab and the rest are in the background. It's a small feature, but it's the thing that makes a thread feel like a place you can return to rather than a record of where you've been.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>With the dashboard wired up, the entire pipeline is finally usable end to end through the interface. Reload the extension, open the dashboard, and you should see the welcome screen prompting you to scan.</p>
<p>Click through scan, build sessions, build your intent map, and the threads should appear, grouped by status. Add an Anthropic key, optionally a context.dev key, and click "Label &amp; enrich" to see titles and next steps sharpen. The full loop you've built across every previous section now runs from a single screen.</p>
<p>What remains is the conversational layer on the right: an AI assistant that can reason across all your threads at once and answer questions like "what should I close this week?" The next section builds it.</p>
<h2 id="heading-how-to-build-the-ai-assistant">How to Build the AI Assistant</h2>
<p>The labeling step asks Claude to describe one thread at a time. The assistant asks something harder: to reason across all of your threads together and answer open-ended questions about them, like what to close this week, what you've stalled on longest, or how to finish a particular one.</p>
<p>This is a chat interface, but a constrained one – grounded entirely in your own thread data, so its answers reference real threads by name rather than offering generic productivity advice.</p>
<p>The whole design rests on one idea: a chat assistant is only as good as the context it's given. So most of the work here is in building the right grounding context for each message, not in the chat mechanics themselves.</p>
<h3 id="heading-grounding-the-conversation">Grounding the Conversation</h3>
<p>Before any message goes to Claude, the assistant assembles a system prompt describing the user's threads. It does this in one of two modes, depending on whether the user has clicked into a specific thread.</p>
<p>With no thread selected, it builds a compact digest of every thread. With one selected, it gives rich detail on that thread and a brief list of the others.</p>
<pre><code class="language-typescript">function buildGroundingContext(
  threads: IntentThread[],
  brands: Map&lt;string, Brand&gt;,
  selectedThread: IntentThread | null,
): string {
  if (!selectedThread) {
    const digest = threads
      .map((t) =&gt; {
        const domains = [...new Set(t.sessions.flatMap((s) =&gt; s.domains))].slice(0, 5).join(", ");
        return `- \({t.title} (\){t.status}, \({t.type}): \){t.summary ?? "no summary yet"} | next: \({t.nextStep ?? "none"} | domains: \){domains || "none"}`;
      })
      .join("\n");

    return `\({SYSTEM_INSTRUCTION}\n\nHere is a digest of all the user's open intent threads:\n\){digest || "(no threads yet)"}`;
  }

  const keywords = [...new Set(selectedThread.sessions.flatMap((s) =&gt; s.keywords))].slice(0, 10).join(", ");
  const domains = [...new Set(selectedThread.sessions.flatMap((s) =&gt; s.domains))].slice(0, 5);

  const domainLines = domains
    .map((d) =&gt; {
      const brand = brands.get(d);
      if (brand?.description) return `- \({d}: \){brand.name} — ${brand.description}`;
      return `- ${d}`;
    })
    .join("\n");

  const sampleTitles = [...new Set(selectedThread.sessions.flatMap((s) =&gt; s.events.map((e) =&gt; e.title)))]
    .slice(0, 20)
    .map((t) =&gt; `- ${t}`)
    .join("\n");

  const otherTitles = threads
    .filter((t) =&gt; t.id !== selectedThread.id)
    .map((t) =&gt; t.title)
    .join(", ");

  return `${SYSTEM_INSTRUCTION}

The user is focused on this thread:
Title: ${selectedThread.title}
Status: ${selectedThread.status}
Type: ${selectedThread.type}
Summary: ${selectedThread.summary ?? "none"}
Next step: ${selectedThread.nextStep ?? "none"}
Keywords: ${keywords || "none"}

Domains visited:
${domainLines || "(none)"}

Recent page titles:
${sampleTitles || "(none)"}

For context, the user's other open threads are: ${otherTitles || "none"}.`;
}
</code></pre>
<p>The two modes match the two kinds of questions people ask. A question like "what should I close this week?" is about the whole set, so the digest mode gives Claude a one-line summary of every thread. This is enough breadth to compare and prioritize across all of them.</p>
<p>A question like "how do I finish this one?", on the other hand, is about a single thread, so the focused mode trades breadth for depth. It hands over that thread's keywords, its domains with their brand descriptions, and up to twenty real page titles, while still naming the other threads so Claude knows what else is in play.</p>
<p>The focused mode is where brand grounding shows up again. The same brand records fetched during enrichment get woven into the domain list, so when the user asks about a thread, Claude sees <code>mastra.ai: Mastra — TypeScript framework for building AI agents</code> rather than a bare domain. This is the identical grounding principle from labeling, now applied to conversation.</p>
<p>The system instruction that prefixes both modes pins the assistant to its data:</p>
<pre><code class="language-typescript">const SYSTEM_INSTRUCTION =
  `You are the assistant inside "openloops", a browser extension that reconstructs ` +
  `the user's browsing history into "intent threads" — decisions, research, or ` +
  `plans they started and haven't closed. Help the user understand and act on ` +
  `these open loops. Be concrete: reference the actual threads by name and ` +
  `suggest real next actions. You are grounded only in the thread data provided ` +
  `below — if the user asks about something not present in it, say so plainly ` +
  `rather than guessing.`;
</code></pre>
<p>The final instruction is the important one: telling the model to admit when something isn't in its data, rather than inventing a plausible answer, is what keeps the assistant trustworthy when a user asks about a thread that doesn't exist or a detail the data doesn't contain.</p>
<h3 id="heading-sending-a-message">Sending a Message</h3>
<p>The send function rebuilds the grounding context fresh on every message. The assistant always reflects the current state of the threads (including any that changed since the conversation started) and posts the whole message history to Claude:</p>
<pre><code class="language-typescript">async function send(text: string) {
  const trimmed = text.trim();
  if (!trimmed || sending) return;

  if (!keySaved) {
    setError("Add your Anthropic key above to chat.");
    return;
  }

  setError(null);
  const nextMessages: Message[] = [...messages, { role: "user", content: trimmed }];
  setMessages(nextMessages);
  setInput("");
  setSending(true);

  try {
    const systemPrompt = buildGroundingContext(threads, brands, selectedThread);
    const maxTokens = EFFORT_OPTIONS.find((e) =&gt; e.id === effort)?.maxTokens ?? 1024;

    const response = await fetch("https://api.anthropic.com/v1/messages", {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-api-key": apiKey,
        "anthropic-version": "2023-06-01",
        "anthropic-dangerous-direct-browser-access": "true",
      },
      body: JSON.stringify({
        model,
        max_tokens: maxTokens,
        system: systemPrompt,
        messages: nextMessages.map((m) =&gt; ({ role: m.role, content: m.content })),
      }),
    });

    if (!response.ok) {
      if (response.status === 401) {
        throw new Error("Invalid API key. Check your Anthropic API key and try again.");
      }
      throw new Error(`API request failed: \({response.status} \){response.statusText}`);
    }

    const data: { content: AnthropicContentBlock[] } = await response.json();
    const reply = data.content
      .filter((b) =&gt; b.type === "text" &amp;&amp; b.text)
      .map((b) =&gt; b.text)
      .join("");

    setMessages((prev) =&gt; [...prev, { role: "assistant", content: reply || "(empty response)" }]);
  } catch (err) {
    setError(err instanceof Error ? err.message : "Something went wrong.");
  } finally {
    setSending(false);
  }
}
</code></pre>
<p>The mechanics mirror the labeling request, the same endpoint, the same browser-access header, and the same 401-aware error handling, since both talk to the same API from the same constrained environment. The user's message gets appended to the running <code>messages</code> array, the full array is sent so the model has the conversation so far, and the assembled grounding context rides along as the <code>system</code> prompt. The reply is extracted by concatenating the text blocks from the response, with a fallback string if the model returned nothing usable.</p>
<p>Rebuilding <code>buildGroundingContext</code> on every send rather than once per conversation is a deliberate choice: if the user re-runs the pipeline or labels their threads mid-conversation, the next message reflects the updated data automatically, with no stale snapshot from when the chat began.</p>
<h3 id="heading-model-and-effort-controls">Model and Effort Controls</h3>
<p>The assistant exposes two selectors: which model to use and how much depth to allow. Both are persisted to <code>chrome.storage.local</code> through the same settings pattern as the keys:</p>
<pre><code class="language-typescript">const MODEL_OPTIONS = [
  { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5 — fastest" },
  { id: "claude-sonnet-4-6",          label: "Sonnet 4.6 — balanced" },
  { id: "claude-opus-4-8",            label: "Opus 4.8 — most capable" },
];

const EFFORT_OPTIONS = [
  { id: "low",    label: "Low",    maxTokens: 512 },
  { id: "medium", label: "Medium", maxTokens: 1024 },
  { id: "high",   label: "High",   maxTokens: 2048 },
];
</code></pre>
<p>The model selector spans the speed-versus-capability range: Haiku for quick answers, Opus for harder reasoning over a tangled set of threads. The effort selector maps to <code>max_tokens</code>, controlling how long an answer the model may produce. This is a reasonable proxy for response depth given the Messages API has no dedicated depth control. A user wanting a one-line answer picks Low, while one wanting a reasoned, prioritized plan picks High.</p>
<h3 id="heading-rendering-replies-and-the-empty-state">Rendering Replies and the Empty State</h3>
<p>The assistant renders Claude's replies as Markdown, since the model naturally formats prioritized lists and step-by-step suggestions with headings and bullets. This would look like raw asterisks and hashes if rendered as plain text. Using <code>react-markdown</code>, the reply component is essentially <code>&lt;ReactMarkdown&gt;{m.content}&lt;/ReactMarkdown&gt;</code> for assistant messages, with user messages rendered as plain text. The accompanying styles target the rendered Markdown elements to match the dashboard's type scale.</p>
<p>Before any conversation starts, the panel shows an empty state with a one-line explanation and a few suggested prompts as clickable chips, "What should I close this week?", "Summarize my open loops", "What have I stalled on longest?". These both demonstrate what the assistant can do and give a one-click way to start.</p>
<p>The suggested prompts shift slightly when a thread is focused, offering "How do I finish this one?" in place of the whole-set summary, matching the focused grounding mode.</p>
<p>A privacy line sits permanently below the composer, stating that chats send thread titles and summaries to Anthropic and nothing else leaves the device. This is the same honest disclosure principle applied throughout, placed where the user will see it before they type.</p>
<h3 id="heading-checkpoint">Checkpoint</h3>
<p>With the assistant in place, openloops is feature-complete. Reload, build your intent map, add your Anthropic key, and try the suggested prompts. Ask what to close this week and the assistant should name specific threads and reason about which are easy wins versus which need a real decision. Click into a single thread and ask how to finish it, and the answer should narrow to that thread's specifics.</p>
<p>The conversation reflects your real, current threads, and nothing about it leaves your machine except the thread summaries you can see in the grounding context itself.</p>
<p>The build is done. The final section steps back to look at what you've made: how it compares to the one mainstream attempt at this idea, what the privacy model adds up to, and where you might take it next.</p>
<h2 id="heading-what-youve-built-and-where-to-take-it">What You've Built, and Where to Take It</h2>
<p>You've built a complete system: browsing history flows in through capture, gets cleaned and segmented into sessions, clustered and scored into intent threads, optionally labeled and grounded by AI, and surfaced through a dashboard with a conversational assistant. Every stage runs on your own machine, and the AI layers are optional additions on top of a pipeline that works without them.</p>
<p>If the clustering reminds you of Chrome's old <a href="https://blog.google/products-and-platforms/products/chrome/finding-answers-gets-better-chrome/">Journeys</a> feature, that's a fair connection. Grouping history by topic instead of by time is the same starting point.</p>
<p>openloops takes it further: every thread carries a confidence score and a status, the AI layer adds labels and a concrete next step, the assistant reasons across threads on demand, and the whole thing is open source and local-first. This means that you can read and change exactly what it does with your data.</p>
<h3 id="heading-what-the-privacy-model-adds-up-to">What the Privacy Model Adds Up To</h3>
<p>Privacy shaped the build at every step, and it's worth collecting what that amounted to in one place. The entire core pipeline, capture through scored threads, runs locally in IndexedDB with no network calls of any kind. Your browsing history – the raw events, the sessions, the threads – never leaves your machine for the parts of the system that work without a key.</p>
<p>The two AI layers are the only paths by which any data leaves the device, and both are opt-in, gated on you providing your own API key. When they run, what they send is deliberately minimal: brand enrichment sends only bare domain names to context.dev, never URLs or page contents, and stripped of any local addresses first. Labeling and the assistant send thread titles, summaries, keywords, and sample page titles to Anthropic, the grounding context you can read directly in the code, and nothing more. Keys themselves live in <code>chrome.storage.local</code>, which never syncs.</p>
<h3 id="heading-where-to-take-it-next">Where to Take it Next</h3>
<p>The build leaves a few deliberate simplifications that make good exercises.</p>
<p>The most satisfying one builds directly on code you've already written. The domain side has <code>ambient.ts</code>, which drops domains that appear on most of your active days. But the keyword side has no equivalent, so a word that's ubiquitous <em>for you</em> (say <code>typescript</code>, if you're a TypeScript developer) survives in every session's keywords and can nudge unrelated threads together.</p>
<p>The fix is a frequency-based keyword detector that mirrors <code>detectAmbientDomains</code> almost line for line, counting days-per-keyword instead of days-per-domain:</p>
<pre><code class="language-typescript">export function detectAmbientKeywords(sessions: Session[]): Set&lt;string&gt; {
  const allEvents = sessions.flatMap((s) =&gt; s.events);
  const activeDays = new Set(allEvents.map((e) =&gt; new Date(e.visitedAt).toDateString()));
  const totalActiveDays = activeDays.size;
  if (totalActiveDays &lt; MIN_ACTIVE_DAYS) return new Set();

  const keywordDayMap = new Map&lt;string, Set&lt;string&gt;&gt;();
  for (const session of sessions) {
    const day = new Date(session.startedAt).toDateString();
    for (const kw of session.keywords) {
      if (!keywordDayMap.has(kw)) keywordDayMap.set(kw, new Set());
      keywordDayMap.get(kw)!.add(day);
    }
  }

  const ambient = new Set&lt;string&gt;();
  for (const [kw, days] of keywordDayMap) {
    if (days.size / totalActiveDays &gt;= UBIQUITY_THRESHOLD) ambient.add(kw);
  }
  return ambient;
}
</code></pre>
<p>You'd then strip these keywords inside <code>similarity</code> exactly as ambient domains are stripped today, filtering them out of both <code>sessionKeywords</code> and the thread's <code>keywordSet</code> before the Jaccard call.</p>
<p>Two smaller exercises round it out. The session gap, similarity threshold, and ambient ubiquity threshold are all hardcoded constants. Lifting them into a settings panel backed by <code>chrome.storage.local</code> (the same store the API keys already use) would let you tune clustering to your own browsing.</p>
<p>And <code>extractDomain</code> strips only a leading <code>www.</code>, so <code>news.bbc.co.uk</code> and <code>bbc.co.uk</code> are treated as different domains. Swapping its hostname logic for a library that uses the <a href="https://publicsuffix.org/">Public Suffix List</a> (the canonical list of domain suffixes like <code>.co.uk</code> that browsers use to know where a registrable domain actually ends) would collapse subdomains of the same site correctly.</p>
<p>Since the whole pipeline is local and inspectable, each of these is straightforward to try against your own real data and see the effect immediately.</p>
<h2 id="heading-wrapping-up">Wrapping up</h2>
<p>openloops turns the flat, chronological record your browser keeps into a map of what you were actually trying to do, and helps you close the loops you left open.</p>
<p>The engineering underneath&nbsp;– time-gap segmentation, weighted Jaccard clustering with ambient-domain correction, heuristic scoring, AI labeling grounded in real company data, and a conversational layer over the result – is the kind of layered system where each stage is simple on its own and the value comes from how they compose.</p>
<h2 id="heading-resources">Resources</h2>
<h3 id="heading-source-code">Source Code</h3>
<ul>
<li>The complete source is available on <a href="https://github.com/sholajegede/openloops">GitHub</a> under the MIT license, so you can run it, read it, and reshape it to fit how you browse. If it helped you, consider giving it a star.</li>
</ul>
<h3 id="heading-core-documentation">Core Documentation</h3>
<ul>
<li><p><a href="https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3">Chrome Extensions: Manifest V3</a>: the extension platform openloops is built on</p>
</li>
<li><p><a href="https://developer.chrome.com/docs/extensions/reference/api/history">chrome.history API</a>: the <code>search</code> and <code>getVisits</code> methods the backfill relies on</p>
</li>
<li><p><a href="https://developer.chrome.com/docs/extensions/reference/api/tabs">chrome.tabs API</a>: <code>onUpdated</code> for live capture and <code>create</code> for Resume</p>
</li>
<li><p><a href="http://chrome.storage">chrome.storage</a> <a href="https://developer.chrome.com/docs/extensions/reference/api/storage">API</a>: where API keys and preferences live, locally</p>
</li>
<li><p><a href="https://docs.claude.com/en/api/messages">Anthropic API reference</a>: the Messages endpoint used for labeling and the assistant</p>
</li>
</ul>
<h3 id="heading-services-used">Services used</h3>
<ul>
<li><p><a href="https://console.anthropic.com/settings/keys">Anthropic Console</a>: create the API key for AI labeling and the assistant</p>
</li>
<li><p><a href="http://context.dev">context.dev</a> <a href="https://docs.context.dev">documentation</a>: the brand-intelligence API used for grounding</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API">IndexedDB (MDN)</a>: the local database every pipeline stage reads and writes</p>
</li>
</ul>
<h3 id="heading-build-tooling">Build tooling</h3>
<ul>
<li><p><a href="https://vitejs.dev/">Vite</a>: the build tool and dev server</p>
</li>
<li><p><a href="https://crxjs.dev/vite-plugin">CRXJS Vite plugin</a>: compiles a Manifest V3 extension with hot reloading</p>
</li>
<li><p><a href="https://github.com/jakearchibald/idb">idb</a>: the typed, promise-based IndexedDB wrapper</p>
</li>
<li><p><a href="https://github.com/remarkjs/react-markdown">react-markdown</a>: renders the assistant's Markdown replies</p>
</li>
</ul>
<h3 id="heading-debugging-tools">Debugging tools</h3>
<ul>
<li><p><a href="https://developer.chrome.com/docs/extensions/get-started/tutorial/debug">Chrome extension service worker DevTools</a>: inspect live-capture logs and the pipeline <code>console.table</code> output</p>
</li>
<li><p>The <strong>Application → IndexedDB</strong> panel in Chrome DevTools: browse <code>raw_events</code>, <code>sessions</code>, <code>intent_threads</code>, and <code>domain_brands</code> directly to verify each stage</p>
</li>
</ul>
<h3 id="heading-further-reading">Further reading</h3>
<ul>
<li><p><a href="https://en.wikipedia.org/wiki/Jaccard_index">Jaccard index</a>: the set-similarity measure behind thread clustering</p>
</li>
<li><p><a href="https://publicsuffix.org/">Public Suffix List</a>: the proper way to extract registrable domains, referenced as a future improvement</p>
</li>
</ul>
<p>If this tutorial was useful, feel free to share it with others who might benefit. I'd really appreciate your thoughts, you can mention me on X at <a href="https://x.com/wani_shola">@wani_shola</a> or <a href="https://linkedin.com/in/sholajegede">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Avoid Overusing useCallback and useMemo in React ]]>
                </title>
                <description>
                    <![CDATA[ If you've spent enough time in the React ecosystem, you'll have likely seen codebases where nearly every function is wrapped with useCallback and the computed value is wrapped with useMemo. The reason ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-avoid-overusing-usecallback-and-usememo-in-react/</link>
                <guid isPermaLink="false">6a32f4091d5034aa7d96e448</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Performance Optimization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Memoization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React.memo ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Olaleye Blessing ]]>
                </dc:creator>
                <pubDate>Wed, 17 Jun 2026 19:22:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/88ac6adf-ef3d-4f28-9dac-22ea12ed5005.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've spent enough time in the React ecosystem, you'll have likely seen codebases where nearly every function is wrapped with <code>useCallback</code> and the computed value is wrapped with <code>useMemo</code>.</p>
<p>The reason behind this is “memoization equals better performance”. But most of the time, this doesn’t really translate to better performance, and it often produces code that's harder to debug.</p>
<p>In this article, you'll learn how to structure your code to avoid overusing <code>useCallback</code> and <code>useMemo</code>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with React hooks and components before reading this tutorial. Familiarity with <code>useState</code>, <code>useEffect</code>, and <code>useRef</code> is assumed. You can read the following freeCodeCamp articles if you need a refresher on <code>useCallback</code> and <code>useMemo</code>:</p>
<ul>
<li><p><a href="https://www.freecodecamp.org/news/caching-in-react/">How to Use the useMemo and useCallback Hooks</a></p>
</li>
<li><p><a href="https://www.freecodecamp.org/news/difference-between-usememo-and-usecallback-hooks/">Difference between the useMemo and useCallback Hooks</a></p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-usecallback-and-usememo-do">What useCallback and useMemo Do</a></p>
<ul>
<li><p><a href="#heading-usememo">useMemo</a></p>
</li>
<li><p><a href="#heading-usecallback">useCallback</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-problem-with-memoization">Problem With Memoization</a></p>
</li>
<li><p><a href="#heading-the-problematic-page">The Problematic Page</a></p>
<ul>
<li><p><a href="#heading-how-to-move-state-down">How to Move State Down</a></p>
<ul>
<li><p><a href="#heading-move-producttable-logic-to-its-component">Move ProductTable Logic To Its Component</a></p>
</li>
<li><p><a href="#heading-move-filtering-logic-to-its-component">Move Filtering Logic To Its Component</a></p>
</li>
<li><p><a href="#heading-move-search-logic-into-its-component">Move Search Logic Into Its Component</a></p>
</li>
<li><p><a href="#heading-move-filter-chips-into-its-component">Move Filter Chips into Its Component</a></p>
</li>
<li><p><a href="#heading-the-final-searchpage">The Final SearchPage</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-fix-your-code-before-reaching-for-these-hooks">Fix Your Code Before Reaching For These Hooks</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-when-to-use-usecallback-and-usememo">When to Use useCallback and useMemo</a></p>
<ul>
<li><p><a href="#heading-measure-before-you-optimize">Measure Before You Optimize</a></p>
</li>
<li><p><a href="#heading-stabilize-references-for-reactmemo-children">Stabilize References for React.memo Children</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-usecallback-and-usememo-do">What <code>useCallback</code> and <code>useMemo</code> Do</h2>
<p>Before moving to how to avoid overusing them, we'll look briefly at what these hooks do.</p>
<h3 id="heading-usememo">useMemo</h3>
<p><code>useMemo</code> caches the return value of a function between re-renders. Imagine you have a sorted list of items in a component:</p>
<pre><code class="language-typescript">interface Item {
  name: string;
  createdAt: string;
}

function App() {
  // == some other states ==
  // == some other states ==
  const [items, setItems] = useState&lt;Item[]&gt;([]);

  const sortedItems = [...items].sort(
    (a, b) =&gt; new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
  );

  return (
    &lt;&gt;
      &lt;ul&gt;
        {sortedItems.map((i) =&gt; (
          &lt;li key={i.name}&gt;{i.name}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>React recomputes <code>sortedItems</code> every time the <code>App</code> component re-renders. This means <code>sortedItems</code> will be recalculated anytime there are any state changes in the <code>App</code> component.</p>
<p>React developers often use <code>useMemo</code> to cache values like this.</p>
<p>Wrapping it with <code>useMemo</code> ensures that <code>sortedItems</code> is only calculated when <code>items</code> actually changes:</p>
<pre><code class="language-typescript">const sortedItems = useMemo(() =&gt; {
  return [...items].sort(
    (a, b) =&gt; new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
  );
}, [items]);
</code></pre>
<h3 id="heading-usecallback">useCallback</h3>
<p><code>useCallback</code> caches the function itself. The function below will be recreated every time some states in the component change:</p>
<pre><code class="language-typescript">function App() {
  // == some other states ==
  // == some other states ==
  const [userId, setUserId] = useState(0);

  const verifyUser = async () =&gt; {
    // update a state to show loading
    console.log("__ Do something with user id __", userId);
    // update a state to remove loading
  };

  return (
    &lt;&gt;
      &lt;button onClick={verifyUser}&gt;Verify&lt;/button&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>Wrapping it with <code>useCallback</code> keeps the same function reference as long as <code>userId</code> hasn’t changed:</p>
<pre><code class="language-typescript">function App() {
  // == some other states ==
  // == some other states ==
  const [userId, setUserId] = useState(0);

  const verifyUser = useCallback(async () =&gt; {
    // update a state to show loading
    console.log("__ Do something with user id __", userId);
    // update a state to remove loading
  }, [userId]);

  return (
    &lt;&gt;
      &lt;button onClick={verifyUser}&gt;Verify&lt;/button&gt;
    &lt;/&gt;
  );
}
</code></pre>
<h2 id="heading-problem-with-memoization">Problem With Memoization</h2>
<p>Nothing is free in life, and memoization is no exception. Every time you use <code>useCallback</code> or <code>useMemo</code>:</p>
<ul>
<li><p>Your app allocates memory to store the cached value and dependency array.</p>
</li>
<li><p>Your component runs a comparison to check if the dependencies have changed</p>
</li>
</ul>
<p>This memoization isn't useful most of the time. Creating a JavaScript function is cheap. Sorting a list of 50 items is cheap. Wrapping these in a memoization hook adds more cost than it prevents. (But keep in mind that if profiling shows sorting is a bottleneck, <code>useMemo</code> is still reasonable there.)</p>
<p>The better approach is to structure your components so that re-renders are less frequent.</p>
<h2 id="heading-the-problematic-page">The Problematic Page</h2>
<p>To see this in action, you'll go through a search page where a parent component manages all the state and logic for the entire page.</p>
<p>To code along, you can clone a simple Next.js project I set up for this:</p>
<pre><code class="language-shell">git clone https://github.com/Olaleye-Blessing/freecodecamp-usecallback-usememo.git

# navigate to the folder
cd freecodecamp-usecallback-usememo

# install the packages
pnpm install

# start development
pnpm dev
</code></pre>
<p>The search page consists of the following:</p>
<ul>
<li><p><a href="https://github.com/Olaleye-Blessing/freecodecamp-avoid-overusing-memoization/blob/main/app/_components/header.tsx">A Header</a> that shows the title of the page.</p>
</li>
<li><p><a href="https://github.com/Olaleye-Blessing/freecodecamp-avoid-overusing-memoization/blob/main/app/_components/search.tsx">A Search field</a> that allows user to search for the name of a product</p>
</li>
<li><p><a href="https://github.com/Olaleye-Blessing/freecodecamp-avoid-overusing-memoization/blob/0d3f5eb7fadc88e8608d0965daf01148c2a35f83/app/_components/header.tsx#L49">A Filter button</a> that opens a drawer for more filtering.</p>
</li>
<li><p><a href="https://github.com/Olaleye-Blessing/freecodecamp-avoid-overusing-memoization/blob/main/app/_components/filter-drawer.tsx">A Drawer</a> for filtering by country, color, mode, and/or price range.</p>
</li>
<li><p><a href="https://github.com/Olaleye-Blessing/freecodecamp-avoid-overusing-memoization/blob/main/app/_components/products-table.tsx">A Product table</a> that shows the search result</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/629122ced97f80b5091d8058/423d6239-2603-4b32-8fe9-080f28d136ad.gif" alt="A demo of the search page. The user searches for &quot;alpine&quot;, clears it, then applies filters in the drawer." style="display:block;margin:0 auto" width="800" height="477" loading="lazy">

<p>All the child components mentioned above maintain no states and functions. They all derive their states and functions from the <code>SearchPage</code> component.</p>
<p>We won’t be going through the child components. They only render the UIs. They have no logic whatsoever.</p>
<p>The <code>SearchPage</code> component looks like this:</p>
<pre><code class="language-typescript">"use client";

import { ChangeEvent, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
  fetchColors,
  fetchCountries,
  fetchModes,
  fetchProducts,
} from "./utils";
import { Header } from "./_components/header";
import { FilterDrawer } from "./_components/filter-drawer";
import { ProductTable } from "./_components/products-table";
import { FilterChips } from "./_components/filter-chips";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FilterState, LocalSortField, SortDir, SortField } from "./interfaces";

const DEFAULTS: FilterState = {
  query: "",
  country: "",
  color: "",
  mode: "",
  minPrice: "",
  maxPrice: "",
  sortField: "name",
  sortDir: "asc",
};

export default function SearchPage() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const [drawerOpen, setDrawerOpen] = useState(false);

  const [localSort, setLocalSort] = useState&lt;{
    field: LocalSortField;
    dir: "asc" | "desc";
  } | null&gt;(null);

  const searchRef = useRef&lt;HTMLInputElement&gt;(null);
  const searchTimerRef = useRef&lt;ReturnType&lt;typeof setTimeout&gt; | null&gt;(null);

  const filters: FilterState = {
    query: searchParams.get("q") ?? DEFAULTS.query,
    country: searchParams.get("country") ?? DEFAULTS.country,
    color: searchParams.get("color") ?? DEFAULTS.color,
    mode: searchParams.get("mode") ?? DEFAULTS.mode,
    minPrice: searchParams.get("minPrice") ?? DEFAULTS.minPrice,
    maxPrice: searchParams.get("maxPrice") ?? DEFAULTS.maxPrice,
    sortField:
      (searchParams.get("sortField") as SortField) ?? DEFAULTS.sortField,
    sortDir: (searchParams.get("sortDir") as SortDir) ?? DEFAULTS.sortDir,
  };

  const apiFilters = {
    query: filters.query,
    country: filters.country || undefined,
    color: filters.color || undefined,
    mode: filters.mode || undefined,
    minPrice: filters.minPrice ? Number(filters.minPrice) : undefined,
    maxPrice: filters.maxPrice ? Number(filters.maxPrice) : undefined,
    sortField: filters.sortField,
    sortDir: filters.sortDir,
  };

  const productsQuery = useQuery({
    queryKey: ["products", apiFilters],
    queryFn: () =&gt; fetchProducts(apiFilters),
  });

  const countriesQuery = useQuery({
    queryKey: ["countries"],
    queryFn: fetchCountries,
    staleTime: Infinity,
  });

  const colorsQuery = useQuery({
    queryKey: ["colors"],
    queryFn: fetchColors,
    staleTime: Infinity,
  });

  const modesQuery = useQuery({
    queryKey: ["modes"],
    queryFn: fetchModes,
    staleTime: Infinity,
  });

  // Updates the filter in the drawer
  const setFilters = (partial: Partial&lt;FilterState&gt;) =&gt; {
    const next = new URLSearchParams(searchParams.toString());
    const merged = { ...filters, ...partial };

    const keyMap: Record&lt;keyof FilterState, string&gt; = {
      query: "q",
      country: "country",
      color: "color",
      mode: "mode",
      minPrice: "minPrice",
      maxPrice: "maxPrice",
      sortField: "sortField",
      sortDir: "sortDir",
    };

    (Object.keys(merged) as (keyof FilterState)[]).forEach((k) =&gt; {
      const paramKey = keyMap[k];
      const val = merged[k];
      const def = DEFAULTS[k];
      if (val &amp;&amp; val !== def) {
        next.set(paramKey, val);
      } else {
        next.delete(paramKey);
      }
    });

    router.push(`\({pathname}?\){next.toString()}`, { scroll: false });
  };

  const resetFilters = () =&gt; {
    router.push(pathname, { scroll: false });
  };

  const handleQueryChange = (e: ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    const val = e.target.value;

    if (searchTimerRef.current) clearTimeout(searchTimerRef.current);

    searchTimerRef.current = setTimeout(() =&gt; {
      setFilters({ query: val });
    }, 400);
  };

  const handleClearQuery = () =&gt; {
    if (searchRef.current) {
      searchRef.current.value = "";
    }

    setFilters({ query: "" });
  };

  const handleColumnClick = (field: LocalSortField) =&gt; {
    setLocalSort((prev) =&gt; {
      if (!prev || prev.field !== field) return { field, dir: "asc" };

      if (prev.dir === "asc") return { field, dir: "desc" };

      return null;
    });
  };

  const hasPriceFilter = filters.minPrice || filters.maxPrice;
  const priceLabel = [
    filters.minPrice ? `$${filters.minPrice}` : null,
    filters.maxPrice ? `$${filters.maxPrice}` : null,
  ]
    .filter(Boolean)
    .join(" - ");

  const activeFilterCount = [
    filters.country,
    filters.color,
    filters.mode,
    filters.minPrice,
    filters.maxPrice,
  ].filter(Boolean).length;

  let sortedProducts = [...(productsQuery.data || [])];
  if (localSort) {
    sortedProducts = [...sortedProducts].sort((a, b) =&gt; {
      const aVal = a[localSort.field];
      const bVal = b[localSort.field];
      const cmp =
        typeof aVal === "string"
          ? aVal.localeCompare(bVal as string)
          : (aVal as number) - (bVal as number);
      return localSort.dir === "desc" ? -cmp : cmp;
    });
  }

  useEffect(() =&gt; {
    return () =&gt; {
      if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
    };
  }, []);

  return (
    &lt;div className="min-h-screen bg-stone-50"&gt;
      &lt;Header
        query={filters.query}
        handleClearQuery={handleClearQuery}
        onToggleFilters={() =&gt; setDrawerOpen((v) =&gt; !v)}
        activeFilterCount={activeFilterCount}
        searchRef={searchRef}
        handleChange={handleQueryChange}
      /&gt;

      &lt;FilterDrawer
        open={drawerOpen}
        onClose={() =&gt; setDrawerOpen(false)}
        filters={filters}
        onChange={setFilters}
        onReset={resetFilters}
        countries={countriesQuery.data ?? []}
        colors={colorsQuery.data ?? []}
        modes={modesQuery.data ?? []}
        activeFilterCount={activeFilterCount}
      /&gt;

      &lt;main className="max-w-6xl mx-auto px-4 py-6"&gt;
        {activeFilterCount &gt; 0 &amp;&amp; (
          &lt;FilterChips
            filters={filters}
            setFilters={setFilters}
            hasPriceFilter={hasPriceFilter}
            priceLabel={priceLabel}
            resetFilters={resetFilters}
          /&gt;
        )}

        &lt;ProductTable
          products={sortedProducts}
          isLoading={productsQuery.isLoading}
          handleColumnClick={handleColumnClick}
          localSort={localSort}
        /&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The <code>SearchPage</code> component keeps track of all the logic needed to render the page:</p>
<ul>
<li><p>It fetches the <code>products</code>, <code>countries</code>, <code>colors</code>, and <code>modes</code>. It passes the <code>countries</code>, <code>colors</code> and <code>modes</code> to the drawer component</p>
</li>
<li><p>It keeps track of the drawer state.</p>
</li>
<li><p>It defines the functions needed to sort the product locally, and so on.</p>
</li>
</ul>
<p>The problem here is that a change in any of the states will lead to recreating all the functions in <code>SearchPage</code> component. For example, when <code>isLoading</code> in the <code>useQuery</code> of products (<code>productsQuery</code>) changes from <code>false</code> to <code>true</code>, all our functions and derived values will be recreated.</p>
<p>The first thing that might come to mind is caching functions and derived values using <code>useCallback</code> and <code>useMemo</code>. While this will work, it will add unnecessary performance overhead to this page.</p>
<p>A better solution is to move state and logic closer to where they are actually used.</p>
<h3 id="heading-how-to-move-state-down">How to Move State Down</h3>
<p>The idea is this: if only one component needs a piece of state or a function, that component should own it. When a child component manages its own state, changes to that state don't re-render the parent. This means all the sibling components’ states and functions stay stable without any memoization.</p>
<p>That said, don’t move logic so far down that shared behavior becomes harder to test or coordinate. The goal isn't to hide every piece of logic inside the deepest possible component. The goal is to place state and logic at the lowest level where they still make sense for the feature.</p>
<h4 id="heading-move-producttable-logic-to-its-component">Move ProductTable Logic to Its Component</h4>
<p>Looking at how products are fetched and sorted, you'll notice that the only component that uses this data is <code>ProductsTable</code>. This means we can move the fetching and sorting logic to the <code>ProductsTable</code>.</p>
<p>The <code>ProductsComponent</code> currently receives its states and logic as props:</p>
<pre><code class="language-typescript">"use client";

interface ProductTableProps {
  products: Product[];
  isLoading: boolean;
  handleColumnClick: (field: LocalSortField) =&gt; void;
  localSort: { field: LocalSortField; dir: SortDir } | null;
}

export function ProductTable({
  products,
  isLoading,
  handleColumnClick,
  localSort,
}: ProductTableProps) {
  // renders the UI using the props
}
</code></pre>
<p>Now, <code>ProductTable</code> will fetch and manage its logic:</p>
<pre><code class="language-typescript">interface ProductTableProps {
  filters: FilterState;
}

export function ProductTable({ filters }: ProductTableProps) {
  const [localSort, setLocalSort] = useState&lt;{
    field: LocalSortField;
    dir: "asc" | "desc";
  } | null&gt;(null);

  const apiFilters = {
    query: filters.query,
    country: filters.country || undefined,
    color: filters.color || undefined,
    mode: filters.mode || undefined,
    minPrice: filters.minPrice ? Number(filters.minPrice) : undefined,
    maxPrice: filters.maxPrice ? Number(filters.maxPrice) : undefined,
    sortField: filters.sortField,
    sortDir: filters.sortDir,
  };

  const { data: products = [], isLoading } = useQuery({
    queryKey: ["products", apiFilters],
    queryFn: () =&gt; fetchProducts(apiFilters),
  });

  const handleColumnClick = (field: LocalSortField) =&gt; {
    setLocalSort((prev) =&gt; {
      if (!prev || prev.field !== field) return { field, dir: "asc" };
      if (prev.dir === "asc") return { field, dir: "desc" };

      return null;
    });
  };

  let sortedProducts = products;
  if (localSort) {
    sortedProducts = [...products].sort((a, b) =&gt; {
      const aVal = a[localSort.field];
      const bVal = b[localSort.field];
      const cmp =
        typeof aVal === "string"
          ? aVal.localeCompare(bVal as string)
          : (aVal as number) - (bVal as number);
      return localSort.dir === "desc" ? -cmp : cmp;
    });
  }

  return &lt;&gt;{/*== renders the UI using the props ==*/}&lt;/&gt;;
}
</code></pre>
<p>Now when <code>isLoading</code> changes, the <code>SearchPage</code> component won’t re-render. This means the derived values and other functions in the <code>SearchPage</code> component won’t be recreated. The only value and function that will be recreated here are the <code>sortedProducts</code> and <code>handleColumnClick</code>.</p>
<p>The <code>SearchPage</code> component becomes this:</p>
<pre><code class="language-typescript">"use client";

import { ChangeEvent, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchColors, fetchCountries, fetchModes } from "./utils";
import { Header } from "./_components/header";
import { FilterDrawer } from "./_components/filter-drawer";
import { ProductTable } from "./_components/products-table";
import { FilterChips } from "./_components/filter-chips";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FilterState, SortDir, SortField } from "./interfaces";

const DEFAULTS: FilterState = {
  query: "",
  country: "",
  color: "",
  mode: "",
  minPrice: "",
  maxPrice: "",
  sortField: "name",
  sortDir: "asc",
};

export default function SearchPage() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const [drawerOpen, setDrawerOpen] = useState(false);

  const searchRef = useRef&lt;HTMLInputElement&gt;(null);
  const searchTimerRef = useRef&lt;ReturnType&lt;typeof setTimeout&gt; | null&gt;(null);

  const filters: FilterState = {
    query: searchParams.get("q") ?? DEFAULTS.query,
    country: searchParams.get("country") ?? DEFAULTS.country,
    color: searchParams.get("color") ?? DEFAULTS.color,
    mode: searchParams.get("mode") ?? DEFAULTS.mode,
    minPrice: searchParams.get("minPrice") ?? DEFAULTS.minPrice,
    maxPrice: searchParams.get("maxPrice") ?? DEFAULTS.maxPrice,
    sortField:
      (searchParams.get("sortField") as SortField) ?? DEFAULTS.sortField,
    sortDir: (searchParams.get("sortDir") as SortDir) ?? DEFAULTS.sortDir,
  };

  const countriesQuery = useQuery({
    queryKey: ["countries"],
    queryFn: fetchCountries,
    staleTime: Infinity,
  });

  const colorsQuery = useQuery({
    queryKey: ["colors"],
    queryFn: fetchColors,
    staleTime: Infinity,
  });

  const modesQuery = useQuery({
    queryKey: ["modes"],
    queryFn: fetchModes,
    staleTime: Infinity,
  });

  // Updates the filter in the drawer
  const setFilters = (partial: Partial&lt;FilterState&gt;) =&gt; {
    const next = new URLSearchParams(searchParams.toString());
    const merged = { ...filters, ...partial };

    const keyMap: Record&lt;keyof FilterState, string&gt; = {
      query: "q",
      country: "country",
      color: "color",
      mode: "mode",
      minPrice: "minPrice",
      maxPrice: "maxPrice",
      sortField: "sortField",
      sortDir: "sortDir",
    };

    (Object.keys(merged) as (keyof FilterState)[]).forEach((k) =&gt; {
      const paramKey = keyMap[k];
      const val = merged[k];
      const def = DEFAULTS[k];
      if (val &amp;&amp; val !== def) {
        next.set(paramKey, val);
      } else {
        next.delete(paramKey);
      }
    });

    router.push(`\({pathname}?\){next.toString()}`, { scroll: false });
  };

  const resetFilters = () =&gt; {
    router.push(pathname, { scroll: false });
  };

  const handleQueryChange = (e: ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    const val = e.target.value;

    if (searchTimerRef.current) clearTimeout(searchTimerRef.current);

    searchTimerRef.current = setTimeout(() =&gt; {
      setFilters({ query: val });
    }, 400);
  };

  const handleClearQuery = () =&gt; {
    if (searchRef.current) {
      searchRef.current.value = "";
    }

    setFilters({ query: "" });
  };

  const hasPriceFilter = filters.minPrice || filters.maxPrice;
  const priceLabel = [
    filters.minPrice ? `$${filters.minPrice}` : null,
    filters.maxPrice ? `$${filters.maxPrice}` : null,
  ]
    .filter(Boolean)
    .join(" - ");

  const activeFilterCount = [
    filters.country,
    filters.color,
    filters.mode,
    filters.minPrice,
    filters.maxPrice,
  ].filter(Boolean).length;

  useEffect(() =&gt; {
    return () =&gt; {
      if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
    };
  }, []);

  return (
    &lt;div className="min-h-screen bg-stone-50"&gt;
      &lt;Header
        query={filters.query}
        onChange={setFilters}
        handleClearQuery={handleClearQuery}
        onToggleFilters={() =&gt; setDrawerOpen((v) =&gt; !v)}
        activeFilterCount={activeFilterCount}
        searchRef={searchRef}
        handleChange={handleQueryChange}
      /&gt;

      &lt;FilterDrawer
        open={drawerOpen}
        onClose={() =&gt; setDrawerOpen(false)}
        filters={filters}
        onChange={setFilters}
        onReset={resetFilters}
        countries={countriesQuery.data ?? []}
        colors={colorsQuery.data ?? []}
        modes={modesQuery.data ?? []}
        activeFilterCount={activeFilterCount}
      /&gt;

      &lt;main className="max-w-6xl mx-auto px-4 py-6"&gt;
        {activeFilterCount &gt; 0 &amp;&amp; (
          &lt;FilterChips
            filters={filters}
            setFilters={setFilters}
            hasPriceFilter={hasPriceFilter}
            priceLabel={priceLabel}
            resetFilters={resetFilters}
          /&gt;
        )}

        &lt;ProductTable filters={filters} /&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The <code>SearchPage</code> component no longer maintains fetching and sorting products data.</p>
<h4 id="heading-move-filtering-logic-to-its-component">Move Filtering Logic To Its Component</h4>
<p>We have different states, data, and functions to make this work:</p>
<ul>
<li><p><code>drawerOpen</code> and <code>setDrawerOpen</code> to control the filter drawer.</p>
</li>
<li><p><code>countries</code>, <code>colors</code> and <code>modes</code> data to allow user to select different options.</p>
</li>
<li><p><code>activeFilterCount</code> to show the number of active filters.</p>
</li>
</ul>
<p>There are two components for the filtering currently. First is a button inside the <code>Header</code> component that looks like this:</p>
<pre><code class="language-typescript">&lt;button
  onClick={onToggleFilters}
  className="relative ml-auto flex items-center gap-2 px-3 py-2 text-sm text-black font-medium border border-stone-300 rounded-lg hover:bg-stone-100 transition"
&gt;
  &lt;SlidersHorizontal className="w-4 h-4" /&gt;
  &lt;span className="hidden sm:inline"&gt;Filters&lt;/span&gt;
  {activeFilterCount &gt; 0 &amp;&amp; (
    &lt;span
      className="absolute -top-1.5 -right-1.5 flex items-center justify-center 
                             w-5 h-5 rounded-full bg-stone-900 text-white text-xs font-bold"
    &gt;
      {activeFilterCount}
    &lt;/span&gt;
  )}
&lt;/button&gt;;
</code></pre>
<p>Second is the <code>FilterDrawer</code> component that looks like this:</p>
<pre><code class="language-typescript">"use client";

import { useEffect, useRef } from "react";
import { X, RotateCcw } from "lucide-react";
import { FilterState } from "../interfaces";
import { SortSection } from "./filter/sort-section";
import { NarrowResultsSection } from "./filter/narrow-result-section";

interface FilterDrawerProps {
  open: boolean;
  onClose: () =&gt; void;
  filters: FilterState;
  onChange: (partial: Partial&lt;FilterState&gt;) =&gt; void;
  onReset: () =&gt; void;
  countries: string[];
  colors: string[];
  modes: string[];
  activeFilterCount: number;
}

export function FilterDrawer({
  open,
  onClose,
  filters,
  onChange,
  onReset,
  countries,
  colors,
  modes,
  activeFilterCount,
}: FilterDrawerProps) {
  const drawerRef = useRef&lt;HTMLDivElement&gt;(null);

  // Close on Escape
  useEffect(() =&gt; {
    const handler = (e: KeyboardEvent) =&gt; {
      if (e.key === "Escape") onClose();
    };
    document.addEventListener("keydown", handler);
    return () =&gt; document.removeEventListener("keydown", handler);
  }, [onClose]);

  // Prevent body scroll while open
  useEffect(() =&gt; {
    document.body.style.overflow = open ? "hidden" : "";
    return () =&gt; {
      document.body.style.overflow = "";
    };
  }, [open]);

  return (
    &lt;&gt;
      {/* Backdrop */}
      &lt;div
        className={`fixed inset-0 z-40 bg-black/30 transition-opacity duration-300 ${
          open
            ? "opacity-100 pointer-events-auto"
            : "opacity-0 pointer-events-none"
        }`}
        onClick={onClose}
      /&gt;

      {/* Drawer panel */}
      &lt;aside
        ref={drawerRef}
        className={`fixed top-0 right-0 z-50 h-full w-80 bg-white shadow-2xl 
                    flex flex-col transition-transform duration-300 ease-in-out
                    ${open ? "translate-x-0" : "translate-x-full"}`}
        aria-hidden={!open}
      &gt;
        {/* Header */}
        &lt;div className="flex items-center justify-between px-5 py-4 border-b border-stone-100"&gt;
          &lt;h2 className="font-semibold text-stone-900"&gt;
            Filters &amp;amp; Sort
            {activeFilterCount &gt; 0 &amp;&amp; (
              &lt;span className="ml-2 text-xs bg-stone-900 text-white px-1.5 py-0.5 rounded-full"&gt;
                {activeFilterCount}
              &lt;/span&gt;
            )}
          &lt;/h2&gt;
          &lt;button
            onClick={onClose}
            className="p-1.5 rounded-md hover:bg-stone-100 transition"
            aria-label="Close filters"
          &gt;
            &lt;X className="w-5 h-5 text-stone-600" /&gt;
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-6"&gt;
          &lt;SortSection
            sortField={filters.sortField}
            sortDir={filters.sortDir}
            onChange={onChange}
          /&gt;

          &lt;hr className="border-stone-100" /&gt;

          &lt;NarrowResultsSection
            filters={filters}
            onChange={onChange}
            countries={countries}
            colors={colors}
            modes={modes}
          /&gt;
        &lt;/div&gt;

        {/* Footer */}
        {activeFilterCount &gt; 0 &amp;&amp; (
          &lt;div className="px-5 py-4 border-t border-stone-100"&gt;
            &lt;button
              onClick={() =&gt; {
                onReset();
                onClose();
              }}
              className="w-full flex items-center justify-center gap-2 px-4 py-2.5 
                         border border-stone-300 rounded-lg text-sm font-medium 
                         hover:bg-stone-100 transition text-stone-700"
            &gt;
              &lt;RotateCcw className="w-4 h-4" /&gt;
              Clear all filters
            &lt;/button&gt;
          &lt;/div&gt;
        )}
      &lt;/aside&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>You can combine the 2 components into a single <code>Filter</code> component that owns all of this logic:</p>
<pre><code class="language-typescript">import { RotateCcw, SlidersHorizontal, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { SortSection } from "./filter/sort-section";
import { NarrowResultsSection } from "./filter/narrow-result-section";
import { FilterState } from "../interfaces";
import { usePathname, useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { fetchColors, fetchCountries, fetchModes } from "../utils";

interface FilterProps {
  filters: FilterState;
  onChange: (partial: Partial&lt;FilterState&gt;) =&gt; void;
}

const Filter = ({ filters, onChange }: FilterProps) =&gt; {
  const { data: countries = [] } = useQuery({
    queryKey: ["countries"],
    queryFn: fetchCountries,
    staleTime: Infinity,
  });

  const { data: colors = [] } = useQuery({
    queryKey: ["colors"],
    queryFn: fetchColors,
    staleTime: Infinity,
  });

  const { data: modes = [] } = useQuery({
    queryKey: ["modes"],
    queryFn: fetchModes,
    staleTime: Infinity,
  });

  const router = useRouter();
  const pathname = usePathname();
  const [drawerOpen, setDrawerOpen] = useState(false);

  const drawerRef = useRef&lt;HTMLDivElement&gt;(null);

  const onClose = () =&gt; setDrawerOpen(false);
  const openDrawer = () =&gt; setDrawerOpen(true);
  const resetFilters = () =&gt; {
    onClose();
    router.push(pathname, { scroll: false });
  };

  const activeFilterCount = [
    filters.country,
    filters.color,
    filters.mode,
    filters.minPrice,
    filters.maxPrice,
  ].filter(Boolean).length;

  // Close on Escape
  useEffect(() =&gt; {
    const handler = (e: KeyboardEvent) =&gt; {
      if (e.key === "Escape") setDrawerOpen(false);
    };
    document.addEventListener("keydown", handler);
    return () =&gt; document.removeEventListener("keydown", handler);
  }, []);

  // Prevent body scroll while open
  useEffect(() =&gt; {
    document.body.style.overflow = drawerOpen ? "hidden" : "";
    return () =&gt; {
      document.body.style.overflow = "";
    };
  }, [drawerOpen]);

  return (
    &lt;&gt;
      &lt;button
        onClick={openDrawer}
        className="relative ml-auto flex items-center gap-2 px-3 py-2 text-sm text-black font-medium border border-stone-300 rounded-lg hover:bg-stone-100 transition"
      &gt;
        &lt;SlidersHorizontal className="w-4 h-4" /&gt;
        &lt;span className="hidden sm:inline"&gt;Filters&lt;/span&gt;
        {activeFilterCount &gt; 0 &amp;&amp; (
          &lt;span
            className="absolute -top-1.5 -right-1.5 flex items-center justify-center 
                             w-5 h-5 rounded-full bg-stone-900 text-white text-xs font-bold"
          &gt;
            {activeFilterCount}
          &lt;/span&gt;
        )}
      &lt;/button&gt;
      {/* Backdrop */}
      &lt;div
        className={`fixed inset-0 z-40 bg-black/30 transition-opacity duration-300 ${
          drawerOpen
            ? "opacity-100 pointer-events-auto"
            : "opacity-0 pointer-events-none"
        }`}
        onClick={onClose}
      /&gt;

      {/* Drawer panel */}
      &lt;aside
        ref={drawerRef}
        className={`fixed top-0 right-0 z-50 h-full w-80 bg-white shadow-2xl 
                    flex flex-col transition-transform duration-300 ease-in-out
                    ${drawerOpen ? "translate-x-0" : "translate-x-full"}`}
        aria-hidden={!drawerOpen}
      &gt;
        {/* Header */}
        &lt;div className="flex items-center justify-between px-5 py-4 border-b border-stone-100"&gt;
          &lt;h2 className="font-semibold text-stone-900"&gt;
            Filters &amp;amp; Sort
            {activeFilterCount &gt; 0 &amp;&amp; (
              &lt;span className="ml-2 text-xs bg-stone-900 text-white px-1.5 py-0.5 rounded-full"&gt;
                {activeFilterCount}
              &lt;/span&gt;
            )}
          &lt;/h2&gt;
          &lt;button
            onClick={onClose}
            className="p-1.5 rounded-md hover:bg-stone-100 transition"
            aria-label="Close filters"
          &gt;
            &lt;X className="w-5 h-5 text-stone-600" /&gt;
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-6"&gt;
          &lt;SortSection
            sortField={filters.sortField}
            sortDir={filters.sortDir}
            onChange={onChange}
          /&gt;

          &lt;hr className="border-stone-100" /&gt;

          &lt;NarrowResultsSection
            filters={filters}
            onChange={onChange}
            countries={countries}
            colors={colors}
            modes={modes}
          /&gt;
        &lt;/div&gt;

        {/* Footer */}
        {activeFilterCount &gt; 0 &amp;&amp; (
          &lt;div className="px-5 py-4 border-t border-stone-100"&gt;
            &lt;button
              onClick={resetFilters}
              className="w-full flex items-center justify-center gap-2 px-4 py-2.5 
                         border border-stone-300 rounded-lg text-sm font-medium 
                         hover:bg-stone-100 transition text-stone-700"
            &gt;
              &lt;RotateCcw className="w-4 h-4" /&gt;
              Clear all filters
            &lt;/button&gt;
          &lt;/div&gt;
        )}
      &lt;/aside&gt;
    &lt;/&gt;
  );
};

export default Filter;
</code></pre>
<p>You can take this even further. Notice that <code>NarrowResultsSection</code> is the only component that uses the fetched <code>countries</code>, <code>colors</code>, and <code>modes</code>. And inside it, each <code>SelectField</code> uses a piece of this data.</p>
<pre><code class="language-typescript">import { FilterState } from "../../interfaces";
import { PriceRangeField } from "./price-range";
import { SelectField } from "./select-field";

interface NarrowResultsSectionProps {
  filters: FilterState;
  onChange: (partial: Partial&lt;FilterState&gt;) =&gt; void;
  countries: string[];
  colors: string[];
  modes: string[];
}

export function NarrowResultsSection({
  filters,
  onChange,
  countries,
  colors,
  modes,
}: NarrowResultsSectionProps) {
  return (
    &lt;section className="flex flex-col gap-4"&gt;
      &lt;h3 className="text-xs font-semibold uppercase tracking-wider text-stone-500"&gt;
        Narrow Results
      &lt;/h3&gt;

      &lt;SelectField
        label="Country"
        value={filters.country}
        options={countries}
        onChange={(v) =&gt; onChange({ country: v })}
        placeholder="All countries"
      /&gt;

      &lt;SelectField
        label="Color"
        value={filters.color}
        options={colors}
        onChange={(v) =&gt; onChange({ color: v })}
        placeholder="All colors"
      /&gt;

      &lt;SelectField
        label="Mode"
        value={filters.mode}
        options={modes}
        onChange={(v) =&gt; onChange({ mode: v })}
        placeholder="All modes"
      /&gt;

      &lt;PriceRangeField
        minPrice={filters.minPrice}
        maxPrice={filters.maxPrice}
        onChange={onChange}
      /&gt;
    &lt;/section&gt;
  );
}
</code></pre>
<p>Instead of fetching everything at the top and passing it down, you can give each <code>SelectField</code> its own query.</p>
<p>The <code>SelectField</code> looked like this:</p>
<pre><code class="language-typescript">interface SelectFieldProps {
  label: string;
  value: string;
  options: string[];
  onChange: (v: string) =&gt; void;
  placeholder: string;
}

export function SelectField({
  label,
  value,
  options,
  onChange,
  placeholder,
}: SelectFieldProps) {
  return &lt;&gt;{/*=== Renders UI ===*/}&lt;/&gt;;
}
</code></pre>
<p>Now, it looks like this:</p>
<pre><code class="language-typescript">import { useQuery } from "@tanstack/react-query";

interface SelectFieldProps {
  label: string;
  value: string;
  onChange: (v: string) =&gt; void;
  placeholder: string;
  queryFn(): Promise&lt;string[]&gt;;
  queryKey: string;
}

export function SelectField({
  label,
  value,
  onChange,
  placeholder,
  queryFn,
  queryKey,
}: SelectFieldProps) {
  const { data: options = [] } = useQuery({
    queryKey: [queryKey],
    queryFn: queryFn,
    staleTime: Infinity,
  });

  return &lt;&gt;{/*=== Renders UI ===*/}&lt;/&gt;;
}
</code></pre>
<p>Now each dropdown manages its own data. A state change inside one <code>SelectField</code> doesn't affect its siblings or its parent.</p>
<h4 id="heading-move-search-logic-into-its-component">Move Search Logic Into Its Component</h4>
<p>The <code>Search</code> component is the only component using the debouncing logic (<code>searchTimerRef</code>, <code>handleQueryChange</code>, <code>handleClearQuery</code>). You'll move logic inside the component:</p>
<pre><code class="language-typescript">"use client";

import { Search as SearchIcon, X } from "lucide-react";
import { ChangeEvent, useEffect, useRef } from "react";
import { FilterState } from "../interfaces";

interface SearchProps {
  query: string;
  onChange: (partial: Partial&lt;FilterState&gt;) =&gt; void;
}

const Search = ({ query, onChange }: SearchProps) =&gt; {
  const searchRef = useRef&lt;HTMLInputElement&gt;(null);
  const searchTimerRef = useRef&lt;ReturnType&lt;typeof setTimeout&gt; | null&gt;(null);

  const handleClearQuery = () =&gt; {
    if (searchRef.current) {
      searchRef.current.value = "";
    }

    onChange({ query: "" });
  };

  const handleQueryChange = (e: ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    const val = e.target.value;

    if (searchTimerRef.current) clearTimeout(searchTimerRef.current);

    searchTimerRef.current = setTimeout(() =&gt; {
      onChange({ query: val });
    }, 400);
  };

  useEffect(() =&gt; {
    return () =&gt; {
      if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
    };
  }, []);

  return &lt;div&gt;{/*=== Renders UI ===*/}&lt;/div&gt;;
};

export default Search;
</code></pre>
<h4 id="heading-move-filter-chips-into-its-component">Move Filter Chips into Its Component</h4>
<p>The <code>FilterChips</code> component renders chips for active filters. The <code>hasPriceFilter</code> and <code>priceLabel</code> values that feed it can live inside the component instead of <code>SearchPage</code>:</p>
<pre><code class="language-typescript">interface FilterChipsProps {
  filters: FilterState;
  setFilters: (partial: Partial&lt;FilterState&gt;) =&gt; void;
  resetFilters: () =&gt; void;
}

export function FilterChips({
  filters,
  setFilters,
  resetFilters,
}: FilterChipsProps) {
  const priceLabel = [
    filters.minPrice ? `$${filters.minPrice}` : null,
    filters.maxPrice ? `$${filters.maxPrice}` : null,
  ]
    .filter(Boolean)
    .join(" - ");

  const hasPriceFilter = filters.minPrice || filters.maxPrice;

  return &lt;&gt;{/*=== Renders UI ===*/}&lt;/&gt;;
}
</code></pre>
<h4 id="heading-the-final-searchpage">The Final SearchPage</h4>
<p>After moving all state and logic to the components that need it, the <code>SearchPage</code> component looks like this:</p>
<pre><code class="language-typescript">"use client";

import { Header } from "./_components/header";
import { ProductTable } from "./_components/products-table";
import { FilterChips } from "./_components/filter-chips";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { FilterState, SortDir, SortField } from "./interfaces";

const DEFAULTS: FilterState = {
  query: "",
  country: "",
  color: "",
  mode: "",
  minPrice: "",
  maxPrice: "",
  sortField: "name",
  sortDir: "asc",
};

export default function SearchPage() {
  const router = useRouter();
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const filters: FilterState = {
    query: searchParams.get("q") ?? DEFAULTS.query,
    country: searchParams.get("country") ?? DEFAULTS.country,
    color: searchParams.get("color") ?? DEFAULTS.color,
    mode: searchParams.get("mode") ?? DEFAULTS.mode,
    minPrice: searchParams.get("minPrice") ?? DEFAULTS.minPrice,
    maxPrice: searchParams.get("maxPrice") ?? DEFAULTS.maxPrice,
    sortField:
      (searchParams.get("sortField") as SortField) ?? DEFAULTS.sortField,
    sortDir: (searchParams.get("sortDir") as SortDir) ?? DEFAULTS.sortDir,
  };

  const setFilters = (partial: Partial&lt;FilterState&gt;) =&gt; {
    const next = new URLSearchParams(searchParams.toString());
    const merged = { ...filters, ...partial };

    const keyMap: Record&lt;keyof FilterState, string&gt; = {
      query: "q",
      country: "country",
      color: "color",
      mode: "mode",
      minPrice: "minPrice",
      maxPrice: "maxPrice",
      sortField: "sortField",
      sortDir: "sortDir",
    };

    (Object.keys(merged) as (keyof FilterState)[]).forEach((k) =&gt; {
      const paramKey = keyMap[k];
      const val = merged[k];
      const def = DEFAULTS[k];
      if (val &amp;&amp; val !== def) {
        next.set(paramKey, val);
      } else {
        next.delete(paramKey);
      }
    });

    router.push(`\({pathname}?\){next.toString()}`, { scroll: false });
  };

  const resetFilters = () =&gt; {
    router.push(pathname, { scroll: false });
  };

  const activeFilterCount = [
    filters.country,
    filters.color,
    filters.mode,
    filters.minPrice,
    filters.maxPrice,
  ].filter(Boolean).length;

  return (
    &lt;div className="min-h-screen bg-stone-50"&gt;
      &lt;Header filters={filters} onChange={setFilters} /&gt;

      &lt;main className="max-w-6xl mx-auto px-4 py-6"&gt;
        {activeFilterCount &gt; 0 &amp;&amp; (
          &lt;FilterChips
            filters={filters}
            setFilters={setFilters}
            resetFilters={resetFilters}
          /&gt;
        )}

        &lt;ProductTable filters={filters} /&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Notice that <code>setFilters</code>, <code>resetFilters</code> and <code>activeFilterCount</code> are still in the <code>SearchPage</code> component. This is intentional. These values depend on the URL. Any component that reads from the URL will re-render whenever the URL changes. It doesn’t matter where the values are calculated.</p>
<h3 id="heading-fix-your-code-before-reaching-for-these-hooks">Fix Your Code Before Reaching For These Hooks</h3>
<p>You might be tempted to reach for <code>useCallback</code> or <code>useMemo</code> when you have infinite re-rendering. Unstable object reference often leads to infinite re-renders, especially when a child component has a <code>useEffect</code> that depends on the object. It’s always better to understand why the loop is happening and fix the root cause.</p>
<p>Look at this example:</p>
<pre><code class="language-typescript">"use client";

import { useEffect, useState } from "react";
import { fetchUsers, Filter, User } from "./utils";

interface UserListProps {
  filters: Filter;
  onLoad(data: User[]): void;
  users: User[];
}

function UserList({ filters, onLoad, users }: UserListProps) {
  console.count("__ USER LIST __");

  useEffect(() =&gt; {
    fetchUsers(filters).then((data) =&gt; {
      onLoad(data);
    });
  }, [filters, onLoad]);

  return (
    &lt;ul className="h-screen flex items-center justify-center flex-col"&gt;
      {users.map((u) =&gt; (
        &lt;li key={u.id}&gt;{u.name}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
}

const UserPage = () =&gt; {
  const [userData, setUserData] = useState&lt;User[]&gt;([]);

  const filters = {
    role: "admin",
    active: true,
  };

  return &lt;UserList filters={filters} onLoad={setUserData} users={userData} /&gt;;
};

export default UserPage;
</code></pre>
<p>The <code>UserList</code> component fetches users when it mounts. It uses <code>filters</code> and <code>onLoad</code> as dependencies.</p>
<p>The problem here is that the <code>filters</code> object in the <code>UserPage</code> component is recreated on every render. Even though the value looks the same, it’s a new reference each time there is a re-render. <code>UserList</code> sees it as a new value every time. This triggers its <code>useEffect</code> because it’s a dependency.</p>
<img src="https://cdn.hashnode.com/uploads/covers/629122ced97f80b5091d8058/c201dff7-3e6b-4648-8caf-9323057df3df.gif" alt="The browser showing infinite log of &quot;USER LIST&quot;." style="display:block;margin:0 auto" width="800" height="477" loading="lazy">

<p>Wrapping <code>filters</code> in <code>useMemo</code> will stop the loop, but it misses the real issue. <code>useMemo</code> isn't meant to stop infinite re-rendering. There are some better solutions to fix this:</p>
<p>The first option is to use primitives in the dependency array instead of objects. Object compares by reference. This is why the <code>useEffect</code> sees different references whenever it reads the <code>filter</code> props. Primitives compare by value.</p>
<pre><code class="language-typescript">function UserList({ filters, onLoad, users }: UserListProps) {
  console.count("__ USER LIST __");

  useEffect(() =&gt; {
    fetchUsers(filters).then((data) =&gt; {
      onLoad(data);
    });
  }, [filters.active, filters.role]); // primitives compare by value, not reference

  return (
    &lt;ul&gt;
      {users.map((u) =&gt; (
        &lt;li key={u.id}&gt;{u.name}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
}
</code></pre>
<p>The second option is to define the object outside the component so it has a stable reference.</p>
<pre><code class="language-typescript">const filters = {
  role: "admin",
  active: true,
};

const UserPage = () =&gt; {
  const [userData, setUserData] = useState&lt;User[]&gt;([]);

  return &lt;UserList filters={filters} onLoad={setUserData} users={userData} /&gt;;
};
</code></pre>
<p>The third solution is to store the object in a state if it's dynamic.</p>
<pre><code class="language-typescript">const UserPage = () =&gt; {
  const [userData, setUserData] = useState&lt;User[]&gt;([]);
  const [filters, setFilters] = useState({
    role: "admin",
    active: true,
  });

  return &lt;UserList filters={filters} onLoad={setUserData} users={userData} /&gt;;
};
</code></pre>
<h2 id="heading-when-to-use-usecallback-and-usememo">When to Use <code>useCallback</code> and <code>useMemo</code></h2>
<p>The goal of this article is not to tell you never to use these hooks. There are real situations where these hooks shine.</p>
<h3 id="heading-measure-before-you-optimize">Measure Before You Optimize</h3>
<p>Before going for any optimization, be it <code>useCallback</code>, <code>useMemo,</code> or restructuring your component, you should first confirm that there is a performance problem. Optimizing code that doesn’t need it isn’t beneficial to anybody.</p>
<p>React DevTools has a Profiler tab that lets you record a session and see exactly which components are re-rendering, how often, and how long each render takes. You should read <a href="https://www.freecodecamp.org/news/how-to-use-react-devtools/">How to Use React Developer Tools – Explained With Examples</a>. If you are a video person, you can watch how Ben shows <a href="https://www.youtube.com/watch?v=00RoZflFE34">how to use the React Profiler to find and fix performance problems</a>.</p>
<h3 id="heading-stabilize-references-for-reactmemo-children">Stabilize References for <code>React.memo</code> Children</h3>
<p><code>React.memo</code> prevents a component from re-rendering if its props haven't changed. But if you pass a function or object as a prop, the child will still re-render on every parent render because functions and objects are recreated with new references each time.</p>
<p>This is the right time to use <code>useCallback</code> or <code>useMemo</code>:</p>
<pre><code class="language-typescript">const Child = React.memo(({ onClick }: { onClick: () =&gt; void }) =&gt; {
  console.log("Child rendered");
  return &lt;button onClick={onClick}&gt;Click me&lt;/button&gt;;
});

function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() =&gt; {
    console.log("clicked");
  }, []);

  return (
    &lt;&gt;
      &lt;button onClick={() =&gt; setCount((c) =&gt; c + 1)}&gt;Increment: {count}&lt;/button&gt;
      &lt;Child onClick={handleClick} /&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>Without <code>useCallback</code>, <code>Child</code> re-renders every time <code>count</code> changes, even though <code>handleClick</code> has nothing to do with <code>count</code>. With <code>useCallback</code>, the function reference stays stable.</p>
<p>It's often best to use both <code>useCallback</code> and <code>React.memo</code> together. But <code>React.memo</code> can be useful by itself if props are primitives or otherwise stable. And <code>useCallback</code> can be useful outside <code>React.memo</code>, such as when passing stable callbacks into effects, custom hooks, or third-party components.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p><code>useCallback</code> and <code>useMemo</code> are useful memoization tools, but they're not a free performance upgrade. Every call adds memory overhead and a dependency comparison on each render.</p>
<p>Always structure your components so that optimization is rarely needed. Move state and logic as close as possible to the components that use them. Use <code>useCallback</code> and <code>useMemo</code> along with <code>React.memo</code> after you confirm that renders are actually a problem.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Stop Trusting AI Code Blindly: A React Code Refactoring Case Study ]]>
                </title>
                <description>
                    <![CDATA[ If you're a developer (or even a little bit familiar with all the AI developments of the past few years), the term Vibe Coding shouldn't be new to you. It is a software development practice where you  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/stop-trusting-ai-code-blindly-a-react-code-refactoring-case-study/</link>
                <guid isPermaLink="false">6a2054b908e3e46121ab26ae</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ refactoring ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tapas Adhikary ]]>
                </dc:creator>
                <pubDate>Wed, 03 Jun 2026 16:22:17 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/87edcb4f-6985-4392-8af5-b0f7daff9f5b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you're a developer (or even a little bit familiar with all the AI developments of the past few years), the term <code>Vibe Coding</code> shouldn't be new to you. It is a software development practice where you describe what you want to AI (an LLM) in plain English, and in response, it gives you the source code for it.</p>
<p>You don't write anything manually line-by-line. You just completely focus on the vibe, like features, look-and-feel, and so on – and the AI generates the actual code for you. It's amazing and powerful.</p>
<p>Like millions of other software developers, I use and advocate the use of AI to a great extent. We should be using AI as a tool to expedite deliverables, to get repetitive work done, to make boilerplate, and anything that AI can help us with to stay productive.</p>
<p>But we shouldn't be doing any of this blindly, especially when it comes to delivering AI-generated work to customers.</p>
<p>All the modern AI tools like Claude, Gemini, or ChatGPT provide a warning upfront that AI can make mistakes. And we as users must double-check the responses before using them. Here's a similar notice from Claude:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5c9bb4026656f09759cdc1f0/060451dc-a9d4-45d2-95b7-0b22a11cc29b.png" alt="Claude AI notice" style="display:block;margin:0 auto" width="701" height="47" loading="lazy">

<p>The main message is this: don't trust AI-generated code blindly. You must do your due diligence before you think of pushing it to production.</p>
<p>To illustrate this, in this article you'll learn from a recent case study I did on generating some React-based source code for an Analytics Dashboard app with AI.</p>
<p>The AI gave me some error-free source code that I could run to see the app. But when I started digging deeper into it, I found potential bugs and tech debt that I needed to address. The generated source code was far from being ready for production and needed a great deal of refactoring.</p>
<p>This guide is also available as a video tutorial as part of the <a href="https://www.youtube.com/playlist?list=PLIJrr73KDmRwySan3tObLmLZp0NYWSmCT">Full-Stack: Vibe Coding to Production Ready</a> series. You can check it out if you’d like:</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/NMkUVKue2jk" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<p>Let's start.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-prompt">The Prompt</a></p>
</li>
<li><p><a href="#heading-the-generated-react-code">The Generated React Code</a></p>
</li>
<li><p><a href="#heading-the-dashboard-app">The Dashboard App</a></p>
</li>
<li><p><a href="#heading-the-code-walkthrough-and-identifying-problems">The Code Walkthrough and Identifying Problems</a></p>
<ul>
<li><p><a href="#heading-problem-1-the-god-component-syndrome">Problem 1: The God Component Syndrome</a></p>
</li>
<li><p><a href="#heading-problem-2-the-state-soup-problem">Problem 2: The State Soup Problem</a></p>
</li>
<li><p><a href="#heading-problem-3-the-data-fetching-anti-pattern">Problem 3: The Data Fetching Anti-Pattern</a></p>
</li>
<li><p><a href="#heading-problem-4-the-missing-types-problem">Problem 4: The Missing Types Problem</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-refactoring-the-ai-generated-code">Refactoring the AI-Generated Code</a></p>
<ul>
<li><p><a href="#heading-refactoring-strategy">Refactoring Strategy</a></p>
</li>
<li><p><a href="#heading-define-types">Define Types</a></p>
</li>
<li><p><a href="#heading-break-the-monoliths">Break the Monoliths</a></p>
</li>
<li><p><a href="#heading-custom-hook-to-handle-data">Custom Hook to Handle Data</a></p>
</li>
<li><p><a href="#heading-everything-together">Everything Together</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-a-task-for-you">A Task for You</a></p>
</li>
<li><p><a href="#heading-key-takeaways">Key Takeaways</a></p>
</li>
<li><p><a href="#heading-if-youve-read-this-far">If You've Read This Far...</a></p>
</li>
</ol>
<h2 id="heading-the-prompt">The Prompt</h2>
<p>First, we need a prompt to inform the AI in plain English that it should generate the source code for the Analytics Dashboard.</p>
<p>Here is the prompt – read it carefully:</p>
<pre><code class="language-markdown">Act as an expert React developer. 

I need a complex 'Creator Analytics Dashboard' for a video platform created using React.

It should include: 

- 1. A header with a user profile. 
- 2. Three summary cards showing total views, videos, and comments. 
- 3. A data table showing recent videos with their individual stats. 
- 4. A sidebar with navigation options. 

Use Tailwind CSS for styling. 

Fetch fake data for the dashboard using fetch with a 1-second timeout to simulate a network request. 

Make it in a way so that I can copy and paste it easily.
</code></pre>
<p>It's a straightforward request to create a Creator Analytics Dashboard with a bunch of feature requests using React and TailwindCSS.</p>
<p>Now, if we take up this prompt and use it on any of the AI tools like ChatGPT, Claude, or Gemini, it will generate the source code for us automatically.</p>
<h2 id="heading-the-generated-react-code">The Generated React Code</h2>
<p>Here's the AI-generated source code from the prompt. As you can see, it's a complete React component with state management, side effect management, and JSX.</p>
<pre><code class="language-typescript">
import { useEffect, useState } from "react";

export default function CreatorDashboard() {
  const [stats, setStats] = useState({ views: 0, subs: 0, revenue: 0 });
  const [videos, setVideos] = useState([]);
  const [comments, setComments] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() =&gt; {
    let isMounted = true;

    const fetchData = async () =&gt; {
      try {
        await new Promise((resolve) =&gt; setTimeout(resolve, 1000));

        if (isMounted) {
          setStats({ views: '1.2M', subs: '45K', revenue: '$3,400' });
          setVideos([
            { id: 1, title: 'Vibe Coding Explained', views: '100K' },
            { id: 2, title: 'React 19 Features', views: '85K' }
          ]);
          setComments([{ id: 1, text: 'Great video!' }]);
          setIsLoading(false);
        }
      } catch (err) {
        setError('Failed to fetch data');
        setIsLoading(false);
      }
    };

    fetchData();
    return () =&gt; { isMounted = false; };
  }, []);

  if (isLoading) return &lt;div className="p-10 text-center text-xl"&gt;Loading Dashboard...&lt;/div&gt;;
  if (error) return &lt;div className="text-red-500 p-10"&gt;{error}&lt;/div&gt;;

  return (
    &lt;div className="flex bg-gray-100 min-h-screen"&gt;
      
      &lt;div className="w-64 bg-white shadow-md p-4"&gt;
        &lt;h2 className="text-2xl font-bold mb-6 text-blue-600"&gt;tapaScript&lt;/h2&gt;
        &lt;ul&gt;
           &lt;li className="mb-2 p-2 bg-blue-50 text-blue-700 rounded cursor-pointer"&gt;Dashboard&lt;/li&gt;
           &lt;li className="mb-2 p-2 hover:bg-gray-50 rounded cursor-pointer"&gt;Analytics&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/div&gt;

      &lt;div className="flex-1 p-8"&gt;
        
        &lt;header className="flex justify-between items-center mb-8"&gt;
          &lt;h1 className="text-3xl font-bold text-gray-800"&gt;Creator Analytics&lt;/h1&gt;
          &lt;div className="flex items-center gap-2"&gt;
            &lt;div className="w-10 h-10 bg-blue-500 rounded-full"&gt;&lt;/div&gt;
            &lt;span className="font-semibold"&gt;Tapas Adhikary&lt;/span&gt;
          &lt;/div&gt;
        &lt;/header&gt;

        
        &lt;div className="grid grid-cols-3 gap-6 mb-8"&gt;
          &lt;div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100"&gt;
            &lt;h3 className="text-gray-500 text-sm font-medium"&gt;Total Views&lt;/h3&gt;
            &lt;p className="text-3xl font-bold mt-2"&gt;{stats.views}&lt;/p&gt;
          &lt;/div&gt;
        &lt;/div&gt;

        &lt;div className="grid grid-cols-3 gap-8"&gt;
           &lt;div className="col-span-2 bg-white rounded-lg shadow-sm p-6"&gt;
              &lt;h2 className="text-xl font-bold mb-4"&gt;Recent Videos&lt;/h2&gt;
              {videos.map(v =&gt; &lt;div key={v.id} className="border-b p-3"&gt;{v.title}&lt;/div&gt;)}
           &lt;/div&gt;
           &lt;div className="bg-white rounded-lg shadow-sm p-6"&gt;
              &lt;h2 className="text-xl font-bold mb-4"&gt;Recent Comments&lt;/h2&gt;
              {comments.map(c =&gt; &lt;div key={c.id} className="border-b p-3 text-sm text-gray-600"&gt;{c.text}&lt;/div&gt;)}
           &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Note that if you try the same prompt again, it will generate slightly different source code as the LLM's responses are probabilistic and non-deterministic. It can produce different responses for the same prompt across multiple calls.</p>
<p>Alright, let's try out the generated code.</p>
<h2 id="heading-the-dashboard-app">The Dashboard App</h2>
<p>Now, copy that AI-generated code and paste it into any React project. When you run it, you should see a beautiful Creator Analytics Dashboard matching the functionalities mentioned in the prompt.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5c9bb4026656f09759cdc1f0/c28d7c3c-bda8-46b2-91d7-4fb905207823.png" alt="Dashboard UI" style="display:block;margin:0 auto" width="2441" height="1121" loading="lazy">

<p>This is amazing and powerful. As a developer, we must leverage it as much as possible. But as a developer, you also need to act like human guardrails to make sure that the generated code is modular, scalable, and bug-free.</p>
<p>Let's now do the walkthrough of the AI-generated code.</p>
<h2 id="heading-the-code-walkthrough-and-identifying-problems">The Code Walkthrough and Identifying Problems</h2>
<p>Before you read further, go back and read the generated source code once more. This time, slowly, carefully – like a code reviewer.</p>
<p>What have you found? Let's see if your findings match the list from my case study.</p>
<h3 id="heading-problem-1-the-god-component-syndrome">Problem 1: The God Component Syndrome</h3>
<p>In software engineering, we have the <code>Single Responsibility Principle(SRP)</code>. It means a function or component should do exactly one thing.</p>
<p>But here, our <code>CreatorDashboard</code> is acting as a "God Component". It manages state, it fetches data from the network, it renders the sidebar, it renders the header, the card, the tables...everything.</p>
<p>If the marketing team asks you to reuse that Stats Card on the marketing landing page, you simply can't. You need to rewrite it, as it's locked inside the giant file.</p>
<h3 id="heading-problem-2-the-state-soup-problem">Problem 2: The State Soup Problem</h3>
<p>Look at the top of the component. Five different <code>useState</code> declarations. When a component renders, tracking which piece of text triggered it becomes a nightmare. This should either be grouped or, even better, managed by a dedicated data fetching library like TanStack Query.</p>
<p>Remember, the fewer states you manage in your component, the better your life will be as a React developer.</p>
<h3 id="heading-problem-3-the-data-fetching-anti-pattern">Problem 3: The Data Fetching Anti-Pattern</h3>
<p>AI loves to use <code>useEffect</code> for data fetching. It's one of the biggest anti-patterns in modern React. This is because the hook useEffect was never meant for data fetching. It doesn't handle caching, it doesn't handle retries if the network drops, and if the user navigates away and comes back, it forces a hard reload on the data every single time.</p>
<p>Modern React provides a better mechanism for data fetching. I've written a <a href="https://www.freecodecamp.org/news/the-modern-react-data-fetching-handbook-suspense-use-and-errorboundary-explained/">Handbook on how to use Suspense and Error Boundary</a> to handle data fetching in React. You can give it a read.</p>
<h3 id="heading-problem-4-the-missing-types-problem">Problem 4: The Missing Types Problem</h3>
<p>We haven't mentioned TypeScript explicitly in the prompt. So, AI gave us JavaScript by default. Now, the problem is, can we guarantee what the <code>videos</code> array holds? What does a video object look like? We don't know, and our editor also can't help us.</p>
<h2 id="heading-refactoring-the-ai-generated-code">Refactoring the AI-Generated Code</h2>
<p>Now that we've identified the problems, the next logical step is to refactor the code to make it better.</p>
<h3 id="heading-refactoring-strategy">Refactoring Strategy</h3>
<p>The image below shows the refactoring strategy we'll follow. We'll break the giant AI-generated component into logical, smaller components like Header, Sidebar, RecentComments, and so on.</p>
<p>We also need to handle the data outside of the component and make the data fetching mechanism reusable for other components in the application to leverage it. To do this, we'll apply the <code>Custom Hook Pattern</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5c9bb4026656f09759cdc1f0/15e028fc-efd0-428b-bfde-7d851634bfbf.png" alt="Refactored code strategy" style="display:block;margin:0 auto" width="1534" height="942" loading="lazy">

<h3 id="heading-define-types">Define Types</h3>
<p>First, let's define all the types needed for the data objects. We need type definitions for video status, comments, and overall creator status.</p>
<pre><code class="language-typescript">
// We use 'type' or 'interface' in TypeScript to define the shape of an object.

export interface CreatorStat {
  label: string;
  value: string | number;
}

export interface VideoStats {
  id: string; // ID should always be a string (UUID) or number, we'll enforce string here
  title: string;
  views: number;
  publishedAt: string;
}

export interface Comment {
  id: string;
  author: string;
  text: string;
  createdAt: string;
}
</code></pre>
<h3 id="heading-break-the-monoliths">Break the Monoliths</h3>
<p>Next, we'll solve the problem of SRP violation and the problem of <code>CreatorDashboard</code> being a God Component. Refactor the giant component by breaking it into multiple smaller components:</p>
<ul>
<li><strong>Header</strong>: A component represents the header of the analytics dashboard.</li>
</ul>
<pre><code class="language-typescript">function Header() {
    return (
        &lt;header className="flex justify-between items-center mb-8"&gt;
            &lt;h1 className="text-3xl font-bold text-gray-800"&gt;
                Creator Analytics
            &lt;/h1&gt;
            &lt;div className="flex items-center gap-2"&gt;
                &lt;div className="w-10 h-10 bg-blue-500 rounded-full"&gt;&lt;/div&gt;
                &lt;span className="font-semibold"&gt;Tapas Adhikary&lt;/span&gt;
            &lt;/div&gt;
        &lt;/header&gt;
    );
}

export default Header;
</code></pre>
<ul>
<li><strong>Sidebar</strong>: The sidebar component holds the navigation links.</li>
</ul>
<pre><code class="language-typescript">export default function Sidebar() {
    return (
        &lt;div className="w-64 bg-white shadow-md p-4"&gt;
            &lt;h2 className="text-2xl font-bold mb-6 text-blue-600"&gt;
                tapaScript
            &lt;/h2&gt;
            &lt;ul&gt;
                &lt;li className="mb-2 p-2 bg-blue-50 text-blue-700 rounded cursor-pointer"&gt;
                    Dashboard
                &lt;/li&gt;
                &lt;li className="mb-2 p-2 hover:bg-gray-50 rounded cursor-pointer"&gt;
                    Analytics
                &lt;/li&gt;
            &lt;/ul&gt;
        &lt;/div&gt;
    );
}
</code></pre>
<ul>
<li><strong>StatCard</strong>: This component accepts a status label and value and renders them. Note how we've applied the types here on the label and value props.</li>
</ul>
<pre><code class="language-typescript">// 1. We define the Props interface.
// "Props" are the arguments passed into a React component.
// We are enforcing that whoever uses this component MUST pass a label and a value.
interface StatCardProps {
    label: string;
    value: string | number;
}

// 2. We extract the props cleanly using destructuring: { label, value }
function StatCard({ label, value }: StatCardProps) {
    return (
        &lt;div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100 hover:shadow-md transition-shadow"&gt;
            &lt;h3 className="text-gray-500 text-sm font-medium uppercase tracking-wider"&gt;
                {label}
            &lt;/h3&gt;
            &lt;p className="text-3xl font-extrabold mt-2 text-gray-900"&gt;
                {value}
            &lt;/p&gt;
        &lt;/div&gt;
    );
}

export default StatCard;
</code></pre>
<ul>
<li><strong>VideoTable</strong>: This component lists out all the video information. So, it accepts an array of videos. Notice that we've solved the type problem here. Now we know that each video in the videos array is of the <code>VideoStats</code> type that we defined earlier.</li>
</ul>
<pre><code class="language-typescript">
import type { VideoStats } from '../types';

interface VideoTableProps {
  // We expect an array of VideoStats objects.
  videos: VideoStats[];
}

function VideoTable({ videos }: VideoTableProps) {
  if (videos.length === 0) {
    return &lt;div className="p-6 text-center text-gray-500"&gt;No videos uploaded yet.&lt;/div&gt;;
  }

  return (
    &lt;div className="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden"&gt;
      &lt;div className="p-4 border-b border-gray-100 bg-gray-50"&gt;
        &lt;h2 className="text-lg font-bold text-gray-800"&gt;Recent Videos&lt;/h2&gt;
      &lt;/div&gt;
      &lt;ul className="divide-y divide-gray-100"&gt;
        {videos.map((video) =&gt; (
          &lt;li key={video.id} className="p-4 hover:bg-gray-50 flex justify-between items-center"&gt;
            &lt;span className="font-medium text-gray-900"&gt;{video.title}&lt;/span&gt;
            &lt;span className="text-sm bg-blue-100 text-blue-800 py-1 px-3 rounded-full font-semibold"&gt;
              {video.views.toLocaleString()} views
            &lt;/span&gt;
          &lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/div&gt;
  );
}

export default VideoTable;
</code></pre>
<ul>
<li><strong>RecentComments</strong>: A component to show the list of comments.</li>
</ul>
<pre><code class="language-typescript">import type { Comment } from "../types";

interface RecentCommentProps {
    // We expect an array of Comment objects.
    videos: Comment[];
}

function RecentCommentList({ comments }: RecentCommentProps) {
    if (comments.length === 0) {
        return (
            &lt;div className="p-6 text-center text-gray-500"&gt;
                You don't have any comments posted.
            &lt;/div&gt;
        );
    }

    return (
        &lt;div className="bg-white rounded-lg shadow-sm p-6"&gt;
            &lt;h2 className="text-xl font-bold mb-4"&gt;Recent Comments&lt;/h2&gt;
            {comments.map((c) =&gt; (
                &lt;div key={c.id} className="border-b p-3 text-sm text-gray-600"&gt;
                    {c.text}
                &lt;/div&gt;
            ))}
        &lt;/div&gt;
    );
}

export default RecentCommentList;
</code></pre>
<h3 id="heading-custom-hook-to-handle-data">Custom Hook to Handle Data</h3>
<p>Now that we have the components defined, and all of them are presentational components, they need data to render information on the dashboard. Also, we don't want to handle all the states inside our component. A custom hook would be a great choice here.</p>
<p>The hook handles the fetch call to get analytics data and tracks them using the state. We return the needed state values from the hook so that anyone using the hook anywhere would get this information. It's completely reusable.</p>
<pre><code class="language-typescript">import { useEffect, useState } from "react";
import type { Comment, CreatorStat, VideoStats } from "./types";

export function useDashboardData() {
    const [stats, setStats] = useState&lt;CreatorStat[]&gt;([]);
    const [videos, setVideos] = useState&lt;VideoStats[]&gt;([]);
    const [comments, setComments] = useState&lt;Comment[]&gt;([]);
    const [isLoading, setIsLoading] = useState(true);
     const [error, setError] = useState&lt;string | null&gt;(null);

    useEffect(() =&gt; {
        let isMounted = true;

        const fetchData = async () =&gt; {
            try {
                // Simulating an API call
                await new Promise((resolve) =&gt; setTimeout(resolve, 1000));

                if (isMounted) {
                    setStats([
                        { label: "Views", value: "1.2M" },
                        { label: "subs", value: "45K" },
                        { label: "revenue", value: "$3,400" },
                    ]);
                    setVideos([
                        {
                            id: 1,
                            title: "Vibe Coding Explained",
                            views: "100K",
                        },
                        { id: 2, title: "React 19 Features", views: "85K" },
                    ]);
                    setComments([
                        { id: 1, text: "Great video!" },
                        { id: 2, text: "Fantastic video!" },
                    ]);
                    setIsLoading(false);
                }
            } catch (err) {
                setError(`Failed to fetch data: ${err?.message}`);
                setIsLoading(false);
            }
        };

        fetchData();
        return () =&gt; {
            isMounted = false;
        };
    }, []);

    return {
        stats,
        videos,
        comments,
        isLoading,
        error
    }

} 
</code></pre>
<h3 id="heading-everything-together">Everything Together</h3>
<p>Finally, it's time to change the giant <code>CreatorDashboard</code> component. We'll first import all the smaller components created, and then call the hook to get the stats, videos, comments, and loading and error states. After that, it's just about using them.</p>
<pre><code class="language-typescript">import Header from "@/components/Header";
import Sidebar from "@/components/Sidebar";
import RecentCommentList from "./components/RecentComments";
import StatCard from "./components/StatCard";
import VideoTable from "./components/VideoTable";

import { useDashboardData } from "./hooks/useDashboardData";

export default function CreatorDashboard() {
    const { stats, videos, comments, isLoading, error } = useDashboardData();

    if (isLoading)
        return (
            &lt;div className="p-10 text-center text-xl"&gt;Loading Dashboard...&lt;/div&gt;
        );
    if (error) return &lt;div className="text-red-500 p-10"&gt;{error}&lt;/div&gt;;

    return (
        &lt;div className="flex bg-gray-100 min-h-screen"&gt;
            {/* Sidebar Navigation */}
            &lt;Sidebar /&gt;

            &lt;div className="flex-1 p-8"&gt;
                {/* Header */}
                &lt;Header /&gt;

                {/* Stats Cards */}
                &lt;div className="grid grid-cols-3 gap-6 mb-8"&gt;
                    {stats.map((stat) =&gt; (
                        &lt;StatCard
                            key={stat.label}
                            label={stat.label}
                            value={stat.value}
                        /&gt;
                    ))}
                &lt;/div&gt;

                {/* Data Table &amp; Comments - All mashed together */}
                &lt;div className="grid grid-cols-3 gap-8"&gt;
                    &lt;div className="lg:col-span-2"&gt;
                        &lt;VideoTable videos={videos} /&gt;
                    &lt;/div&gt;
                    &lt;RecentCommentList comments={comments} /&gt;
                &lt;/div&gt;
            &lt;/div&gt;
        &lt;/div&gt;
    );
}
</code></pre>
<p>That's all. We have now successfully refactored the big AI-generated component into smaller, reusable components and separated the data layer and state handling outside of it.</p>
<h2 id="heading-a-task-for-you">A Task for You</h2>
<p>This is optional, yet I'd encourage you to try it. The task is to take the refactoring to the next level.</p>
<p>Can you get rid of the <code>useDashboardData</code> hook, and handle the data fetching using the <a href="https://www.freecodecamp.org/news/the-modern-react-data-fetching-handbook-suspense-use-and-errorboundary-explained/">Suspense and Error Boundary patterns</a>? I would love to discuss the solution with you. Please reach out on my socials (given below) or my <a href="https://discord.gg/sSQ7HEYrrZ">Discord Server</a>.</p>
<p>Also, stay tuned for my upcoming article, where I'll refactor the same app with TanStack Query and teach you about fetch, mutation, and caching.</p>
<h2 id="heading-key-takeaways">Key Takeaways</h2>
<p>This is the reality of AI-generated code. It looks like a finished product on the surface. But underneath, it's a fragile house of cards. If you try to scale this, say by adding authentication, sorting to the tables, or real-time comment updates, the file will grow to 1K+ lines of unmaintainable code.</p>
<p>Our job isn't to reject AI's output. Instead, it's to refactor its output to make it production-ready. You can do that only when you have strong fundamentals, and you understand the <a href="https://www.freecodecamp.org/news/the-new-definition-of-software-engineering-in-the-age-of-ai/">new definition of software engineering in the age of AI</a>.</p>
<h2 id="heading-if-youve-read-this-far"><strong>If You've Read This Far...</strong></h2>
<p>Thank You!</p>
<p>I'm thrilled to announce that I've started a <a href="https://www.youtube.com/playlist?list=PLIJrr73KDmRwySan3tObLmLZp0NYWSmCT">Full Stack FREE Course</a> to take developers from vibe coding to a production-ready mental model. I'd be delighted if you check it out and take part.</p>
<ul>
<li><p>Subscribe to my <a href="https://www.youtube.com/tapasadhikary?sub_confirmation=1">YouTube Channel</a></p>
</li>
<li><p>Follow on <a href="https://www.linkedin.com/in/tapasadhikary/">LinkedIn</a> and <a href="https://x.com/tapasadhikary">X</a></p>
</li>
<li><p>Catch up with my <a href="https://www.tapascript.io/books/react-clean-code-rule-book">React Clean Code Rules Book</a></p>
</li>
<li><p>All the source code used in this article is on my <a href="https://github.com/tapascript/full-stack-vibe-to-prod">GitHub Repository</a>.</p>
</li>
</ul>
<p>See you soon with my next article. Until then, please take care of yourself and keep learning.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Build Professional Web Scrapers That Actually Work ]]>
                </title>
                <description>
                    <![CDATA[ Web scraping has evolved. If you’ve ever tried to pull data from a site, only to be hit with a CAPTCHA, an IP ban, or a "403 Forbidden" error, you know that modern websites are built to block automate ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-professional-web-scrapers-that-actually-work/</link>
                <guid isPermaLink="false">6a19ae75b55c6a731d1d3963</guid>
                
                    <category>
                        <![CDATA[ Scraping ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Fri, 29 May 2026 15:19:17 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/504ac9dd-9526-4ee7-829c-3c3d1661eb24.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Web scraping has evolved. If you’ve ever tried to pull data from a site, only to be hit with a CAPTCHA, an IP ban, or a "403 Forbidden" error, you know that modern websites are built to block automated scripts.</p>
<p>To get the data you need today, you have to bypass sophisticated anti-bot detection systems.</p>
<p>We are just posted full-stack web scraping course on the <a href="http://freeCodeCamp.org">freeCodeCamp.org</a> YouTube channel. Gavin Lon developed this course.</p>
<p>Many scraping tutorials focus on basic scripts that fail the moment they hit a real-world website. This course bridges the gap between a "toy script" and a production-ready application. You'll learn how to bypass advanced fingerprinting and bot detection using managed browser infrastructure and residential proxies.</p>
<p>Gavin will teach you how to build a fully deployed MERN (MongoDB, Express, React, Node.js) application. It's a dashboard that visualizes live data scraped from major platforms like Amazon, Booking.com, Indeed, and the TIOBE Index.</p>
<p>Evomi provided a grant to make this course possible. You can try out Evomi here: <a href="https://evomi.com/freecodecamp">https://evomi.com/freecodecamp</a></p>
<p>Here are the key things you will learn in the course:</p>
<ul>
<li><p><strong>Master Modern Scraping:</strong> Move beyond basic libraries to use Playwright, Cheerio, and Evomi’s enterprise-grade Scraping Browser and Scraper API.</p>
</li>
<li><p><strong>Defeat Anti-Bot Systems:</strong> Learn exactly why standard scripts get flagged and how to configure residential proxies and browser fingerprints to remain undetected.</p>
</li>
<li><p><strong>Full-Stack Integration:</strong> Learn how to pipeline raw data into a MongoDB database and build a clean, responsive UI with React, Vite, and Bootstrap.</p>
</li>
</ul>
<p>Watch the full course on <a href="https://youtu.be/V1JmI5sUc5E">the freeCodeCamp.org YouTube channel</a> (6-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/V1JmI5sUc5E" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Create Dynamic Emails in Go with React Email  ]]>
                </title>
                <description>
                    <![CDATA[ Backend applications are required to send emails to users to deliver notifications and maintain communication outside the application interface. These emails usually contain information specific to ea ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-create-dynamic-emails-in-go-with-react-email/</link>
                <guid isPermaLink="false">69e689acc9501dd0102dc758</guid>
                
                    <category>
                        <![CDATA[ Go Language ]]>
                    </category>
                
                    <category>
                        <![CDATA[ golang ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Orim Dominic Adah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Apr 2026 20:16:44 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/62917f79-c4d8-40e2-8eb7-87b63560e546.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Backend applications are required to send emails to users to deliver notifications and maintain communication outside the application interface. These emails usually contain information specific to each user, such as the user's name or address, making them dynamic.</p>
<p>This article walks you through building a dynamic email template with React Email, converting it to HTML, and injecting data into it using Go templates. It also contains an optional section that shows you how to send and test the email delivery with MailHog.</p>
<p>To follow along with this article, you need to have Go and Node.js installed on your computer. You should also have a basic understanding of React and some familiarity with Go templates, though these aren't strict requirements because you can pick them up as you practise along.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-react-email">What is React Email?</a></p>
</li>
<li><p><a href="#heading-go-templates">Go Templates</a></p>
<ul>
<li><a href="#heading-go-template-delimiters">Go Template Delimiters</a></li>
</ul>
</li>
<li><p><a href="#heading-create-dynamic-emails-in-go-with-react-email">Create Dynamic Emails in Go with React Email</a></p>
<ul>
<li><p><a href="#heading-set-up-the-project">Set Up the Project</a></p>
</li>
<li><p><a href="#heading-set-up-react-email">Set Up React Email</a></p>
</li>
<li><p><a href="#heading-create-a-react-email-template">Create a React Email Template</a></p>
</li>
<li><p><a href="#heading-set-up-go-templates-from-html-files">Set Up Go Templates from HTML Files</a></p>
</li>
<li><p><a href="#heading-render-the-dynamic-email-in-the-browser">Render the Dynamic Email in the Browser</a></p>
</li>
<li><p><a href="#heading-send-and-test-email-with-go-mail-and-mailhog">Send and Test Email with go-mail and MailHog</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-react-email">What is React Email?</h2>
<p><a href="https://react.email/">React Email</a> is a JavaScript library that helps you build dynamic email templates with React. If you already know basic React, React Email provides a better developer experience for building dynamic email templates. Here are some reasons why:</p>
<ul>
<li><p><strong>Familiar syntax with React:</strong> If you know React already, React Email eliminates the hassle in learning a separate templating language, using inefficient drag-and-drop UIs, or writing emaiil templates from scratch with HTML tables.</p>
</li>
<li><p><strong>Reusable built-in components</strong>: React Email provides ready-to-use UI components like <a href="https://react.email/components/buttons">Buttons</a> and <a href="https://react.email/components/footers">Footers</a> so you don't have to start from scratch, making email development seamless and fast.</p>
</li>
<li><p><strong>Consistency across email clients</strong>: React Email generates email templates that have been tested and work well across popular email clients. This helps eliminate worries over emails rendering inconsistently across email clients.</p>
</li>
<li><p><strong>Email development tooling</strong>: React Email has features for previewing and assessing emails built with it. Some of these features include:</p>
<ul>
<li><p>A local development server that lets you preview mobile and desktop views of your emails in your web browser as you develop the emails in real time</p>
</li>
<li><p>An email delivery feature that sends your email to a real email address for preview</p>
</li>
<li><p>A compatibility checker that shows you how well your email is supported across popular email clients</p>
</li>
<li><p>A spam scorer that analyses your email to determine if it's likely to be marked as spam</p>
</li>
</ul>
</li>
<li><p><strong>Tailwind integration</strong>: <a href="https://tailwindcss.com/">Tailwind</a> is a popular CSS framework that provides classes for styling HTML and making it responsive. React Email integrates with Tailwind easily for creating beautiful emails.</p>
</li>
</ul>
<p>All these features are free to use.</p>
<p>In this article, you'll learn how to generate an HTML file from a React Email template, convert it to a Go template, and inject data into the template for previewing.</p>
<h2 id="heading-go-templates">Go Templates</h2>
<p>The Go <a href="https://pkg.go.dev/html/template">html/template</a> package allows you to define reusable HTML templates that can be populated with dynamic data. These templates contain placeholders (called actions) that are evaluated by Go's templating engine and replaced with actual values during execution.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/7ff18217-2ff1-43fc-96b5-01681fbd0ac5.png" alt="Golang HTML template parsing and execution" style="display:block;margin:0 auto" width="974" height="397" loading="lazy">

<p>First, you give the package HTML content that contains Go-specific annotations. It converts the HTML content to a Go HTML template and the Go-specific annotations to actions in the template. The template is then executed with data to produce HTML output that contains the data.</p>
<pre><code class="language-go">package main

import (
	"html/template"
	"os"
)

func main() {
	tmpl := template.New("hello")
	tmpl, _ = tmpl.Parse(`&lt;p&gt;Hello {{.}}&lt;/p&gt;`)
	tmpl.Execute(os.Stdout, "Gopher")
}

// Output: &lt;p&gt;Hello Gopher&lt;/p&gt;
// Playground: https://goplay.tools/snippet/KxbkWPIArz5
</code></pre>
<p>In the code snippet above:</p>
<ul>
<li><p><code>template.New</code> creates an empty template object with the name "hello"</p>
</li>
<li><p><code>tmpl.Parse(`&lt;p&gt;Hello {{.}}&lt;/p&gt;`)</code> parses the HTML string <code>&lt;p&gt;Hello {{.}}&lt;/p&gt;</code> to create a Go HTML template and saves it in <code>tmpl</code> . The <code>{{.}}</code> part of the HTML string is an action which acts as a placeholder for data. <code>{{</code> and <code>}}</code> are called delimiters and <code>.</code> is the data access identifier.</p>
</li>
<li><p><code>tmpl.Execute(os.Stdout, "Gopher")</code> populates the action with data - the "Gopher", string, and writes the resulting HTML output to the console.</p>
</li>
</ul>
<h3 id="heading-go-template-delimiters">Go Template Delimiters</h3>
<p>In the previous code snippet, you used double curly braces (<code>{{</code> and <code>}}</code>) as the delimiters in the Go template. Delimiters are symbols that Go uses to determine what parts of the input string represent an action – that is, a statement to be evaluated.</p>
<p>You can change the delimiters by using the <code>Delims</code> method on a template. An example is shown in the snippet below:</p>
<pre><code class="language-go">package main

import (
	"html/template"
	"os"
)

func main() {
	tmpl := template.New("hello")
	tmpl, _ = tmpl.Delims("((", "))").Parse(`&lt;p&gt;Hello ((.))&lt;/p&gt;`)
	tmpl.Execute(os.Stdout, "Gopher")
}

// Output: &lt;p&gt;Hello Gopher&lt;/p&gt;
// Playground: https://goplay.tools/snippet/00RuDzvZYwN
</code></pre>
<p>In the snippet above, <code>((</code> and <code>))</code> are used as the delimiters for the <code>hello</code> template.</p>
<p>This is important because you'll set your delimiters to prevent conflicts between Go's default delimiters and React's curly braces in React Email templates.</p>
<h2 id="heading-create-dynamic-emails-in-go-with-react-email">Create Dynamic Emails in Go with React Email</h2>
<p>The image below summarizes how the sample application you'll build in this article works:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/289f0ea5-1de7-46f7-b463-32f6c61fa750.png" alt="From React Email templates to Email HTML with Dynamic Data" style="display:block;margin:0 auto" width="1614" height="704" loading="lazy">

<p>You'll create a React Email template that contains Go template annotations. Next, you'll use Node.js to create HTML files from it. Go will parse the HTML file to create a Go template, execute it, and send it.</p>
<p>Optionally, you'll use go-mail to send the email and MailHog, a local SMTP server, to preview it in your browser.</p>
<h3 id="heading-set-up-the-project">Set Up the Project</h3>
<p>First, make sure that you have Go and Node.js installed on your computer already. Clone this <a href="https://github.com/orimdominic/freeCodeCamp-go-react-email">freeCodeCamp-go-react-email</a> repository and checkout the <code>01-setup</code> branch using <code>git checkout 01-setup</code>.</p>
<p>The project contains a <code>main.go</code> file in the <code>cmd</code> directory and a <code>go.mod</code> file. It also contains a <code>.gitignore</code> file to instruct Git to ignore all <code>node_modules</code> directories.</p>
<p>Run <code>go run cmd/main.go</code> in the terminal of the project. If you see "It works!" logged to the console, you have set it up properly and you can continue to the next section.</p>
<h3 id="heading-set-up-react-email">Set Up React Email</h3>
<p>In the project root directory, create a <code>mailer</code> directory which will serve as the mailer package. It will hold all functionality related to creating and sending mails.</p>
<p>In the <code>mailer</code> directory, you'll create the <code>emails</code> Node.js project that will handle React Email functionality. To create the project:</p>
<ul>
<li><p>Create a directory called <code>_emails</code> in the <code>mailer</code> directory. The name of the directory starts with an underscore because it should be ignored when the <code>go build</code> command is run. So it won't be included in the Go compiled executable file.</p>
</li>
<li><p>Run <code>npm init -y</code> in the root terminal of the <code>_emails</code> directory to initialise the Node.js project in it. This will create a <code>package.json</code> file in the directory.</p>
</li>
<li><p>Update the value of the name field in <code>package.json</code> to "emails" to make the package name more conventional. This step is not compulsory.</p>
</li>
</ul>
<p>Next, install the required React Email libraries by running the following commands in the root terminal of the <code>_emails</code> directory:</p>
<pre><code class="language-shell">npm install @react-email/ui @types/react -D -E
npm install react-email react react-dom -E
</code></pre>
<p>After the installation is complete, replace the <code>scripts</code> field of the <code>package.json</code> file with the code snippet below:</p>
<pre><code class="language-json">  "scripts": {
    "dev": "email dev --dir ./src",
    "export": "email export --pretty --dir ./src --outDir ../templates"
  },
</code></pre>
<p>The <code>dev</code> script starts and runs the server for previewing the React Email templates in the browser. You will write the template with React and store it in the <code>src</code> directory under <code>_emails</code>. The <code>export</code> script transpiles the template files in the <code>src</code> directory from JSX (or TSX) to HTML and stores them in a directory called <code>templates</code>, a direct child of the <code>mailer</code> directory – not a child of the <code>_emails</code> directory.</p>
<p>The <code>templates</code> directory is stored as a child directory of the <code>mailer</code> directory because the Go project needs only the HTML output stored in the <code>templates</code> directory and not the contents of <code>_emails</code>.</p>
<p>If you've completed all these steps, you have set up React Email in the <code>emails</code> Node.js project. To view the current status of the project at this point, visit <a href="https://github.com/orimdominic/freeCodeCamp-go-react-email/tree/02-setup-react-email">freeCodeCamp-go-react-email/02-setup-react-email</a>.</p>
<p>In the next section, you'll create a React Email template and preview it in the browser.</p>
<h3 id="heading-create-a-react-email-template">Create a React Email Template</h3>
<p>In this section, you'll create a React Email template and preview it in the browser. You'll also build and export the template to HTML files.</p>
<p>Create a directory called <code>src</code> inside the <code>_emails</code> directory. Inside the <code>src</code> directory, create a file called <code>welcome.tsx</code>. Copy and paste the content of the snippet below into <code>welcome.tsx</code>.</p>
<pre><code class="language-typescript">import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Html,
  Img,
  Preview,
  Section,
  Tailwind,
  Text,
} from "react-email";

interface WelcomeEmailProps {
  username?: string;
  company?: string;
  gophers?: string[];
}

const WelcomeEmail = ({
  username = "Nicole",
  company = "GoWorld",
  gophers = ["Tinky Winky", "Dipsy", "Laa-Laa", "Po"],
}: WelcomeEmailProps) =&gt; {
  const previewText = `Welcome to \({company}, \){username}!`;

  return (
    &lt;Html&gt;
      &lt;Head /&gt;
      &lt;Preview&gt;{previewText}&lt;/Preview&gt;
      &lt;Tailwind&gt;
        &lt;Body className="m-auto font-sans"&gt;
          &lt;Container className="mb-10 mx-auto p-5 max-w-[465px]"&gt;
            &lt;Section className="mt-10"&gt;
              &lt;Img
                src={`https://storage.googleapis.com/gopherizeme.appspot.com/gophers/69428e5ec867c34bb4a49d5a063fdbc2a6633aed.png`}
                width="80"
                height="80"
                alt="Logo"
                className="my-0 mx-auto"
              /&gt;
            &lt;/Section&gt;
            &lt;Heading className="text-2xl font-normal text-center p-0 my-8 mx-0"&gt;
              Welcome to &lt;strong&gt;{company}&lt;/strong&gt;, {username}!
            &lt;/Heading&gt;
            &lt;Text className="text-start text-base"&gt;Hello {username},&lt;/Text&gt;
            &lt;Text className="text-start text-base leading-relaxed"&gt;
              We're excited to have you onboard at &lt;strong&gt;{company}&lt;/strong&gt;.
              We hope you enjoy your journey with us. If you have any questions
              or need assistance, feel free to reach out to any of the following
              gophers:
            &lt;/Text&gt;
            &lt;div className="text-start text-base leading-relaxed"&gt;
              &lt;ul className="pl-3"&gt;
                {gophers.map((gopher) =&gt; (
                  &lt;li&gt;{gopher}&lt;/li&gt;
                ))}
              &lt;/ul&gt;
            &lt;/div&gt;
            &lt;Section className="text-center mt-[32px] mb-[32px]"&gt;
              &lt;Button
                className="py-2.5 px-5 bg-white rounded-md text-base font-semibold no-underline text-center bg-black text-white"
                href={`https://go.dev`}
              &gt;
                Get Started
              &lt;/Button&gt;
            &lt;/Section&gt;
            &lt;Text className="text-start text-base text-white"&gt;
              Cheers,
              &lt;br /&gt;
              The {company} Team
            &lt;/Text&gt;
          &lt;/Container&gt;
        &lt;/Body&gt;
      &lt;/Tailwind&gt;
    &lt;/Html&gt;
  );
};

export default WelcomeEmail;
</code></pre>
<p>The code snippet above is the React Email template that you'll use in this article. To preview it, navigate to the terminal of the <code>_emails</code> root directory and run <code>npm run dev</code> . Use your web browser to visit the preview URL displayed on the terminal. Click on the "welcome" link on the left sidebar and you should see a UI similar to the one in the screenshot below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/9a1253d2-7502-40d5-9081-7974c7a83f36.png" alt="React Email preview UI" style="display:block;margin:0 auto" width="1062" height="633" loading="lazy">

<p>In the UI above, React Email renders the email with the default values supplied to the <code>welcome</code> email template.</p>
<p>Stop the server by clicking on the terminal that runs it by pressing <code>CTRL + C</code>. Build the HTML output of the <code>src</code> directory and export it by running <code>npm run export</code> in the terminal of the <code>_emails</code> root directory. This creates a <code>templates</code> directory within the <code>mailer</code> directory where the exported HTML files are stored. In the <code>templates</code> directory, you'll see a <code>welcome.html</code> file – the HTML output from <code>welcome.tsx</code>.</p>
<p>To see the current status of the project, visit <a href="https://github.com/orimdominic/freeCodeCamp-go-react-email/tree/03-create-react-email-template">freeCodeCamp-go-react-email/03-create-react-email-template</a>.</p>
<h3 id="heading-set-up-go-templates-from-html-files">Set Up Go Templates from HTML Files</h3>
<p>You have created a React Email template, previewed it, built it, and exported it as an HTML file. In this section, you'll update the React Email template to use the delimiters you set and not React's curly braces. You'll also create a Go template from the HTML file.</p>
<p>To get started, replace the content of <code>welcome.tsx</code> with the code snippet below to to use <code>((</code> and <code>))</code> as delimiters and remove TypeScript types:</p>
<pre><code class="language-typescript">import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Html,
  Img,
  Preview,
  Section,
  Tailwind,
  Text,
} from "react-email";

const WelcomeEmail = () =&gt; {
  const previewText = `Welcome to ((.Company)), ((.Username))!`;

  return (
    &lt;Html&gt;
      &lt;Head /&gt;
      &lt;Preview&gt;{previewText}&lt;/Preview&gt;
      &lt;Tailwind&gt;
        &lt;Body className="m-auto font-sans"&gt;
          &lt;Container className="mb-10 mx-auto p-5 max-w-[465px]"&gt;
            &lt;Section className="mt-10"&gt;
              &lt;Img
                src={`https://storage.googleapis.com/gopherizeme.appspot.com/gophers/69428e5ec867c34bb4a49d5a063fdbc2a6633aed.png`}
                width="80"
                height="80"
                alt="Gopher"
                className="my-0 mx-auto"
              /&gt;
            &lt;/Section&gt;
            &lt;Heading className="text-2xl font-normal text-center p-0 my-8 mx-0"&gt;
              Welcome to &lt;strong&gt;((.Company))&lt;/strong&gt;, ((.Username))!
            &lt;/Heading&gt;
            &lt;Text className="text-start text-base"&gt;Hello ((.Username)),&lt;/Text&gt;
            &lt;Text className="text-start text-base leading-relaxed"&gt;
              We're excited to have you onboard at &lt;strong&gt;((.Company))&lt;/strong&gt;
              . We hope you enjoy your journey with us. If you have any
              questions or need assistance, feel free to reach out to any of the
              following Gophers:
            &lt;/Text&gt;
            &lt;div className="text-start text-base leading-relaxed"&gt;
              &lt;ul className="pl-3"&gt;
                ((range .Gophers))
                &lt;li&gt;((.))&lt;/li&gt;
                ((end))
              &lt;/ul&gt;
            &lt;/div&gt;
            &lt;Section className="text-center mt-[32px] mb-[32px]"&gt;
              &lt;Button
                className="py-2.5 px-5 bg-white rounded-md border text-black text-base font-semibold no-underline text-center"
                href={`https://go.dev`}
              &gt;
                Get Started
              &lt;/Button&gt;
            &lt;/Section&gt;

            &lt;Text className="text-start text-base"&gt;
              Cheers,
              &lt;br /&gt;
              The ((.Company)), Team
            &lt;/Text&gt;
          &lt;/Container&gt;
        &lt;/Body&gt;
      &lt;/Tailwind&gt;
    &lt;/Html&gt;
  );
};

export default WelcomeEmail;
</code></pre>
<p>Run <code>npm run export</code> in the root terminal of the <code>_emails</code> directory to build and export this version of the React Email template to HTML. The HTML generated will contain Go template annotations that will become actions when parsed by Go to form a Go HTML template.</p>
<p>In the <code>mailer</code> directory, create a file named <code>fs.go</code>. The code in the file will be used to embed the files in the <code>templates</code> directory for use in the Go application. Copy and paste the content of the snippet below into <code>fs.go</code>:</p>
<pre><code class="language-go">package mailer

import (
	"embed"
	"io/fs"
)

//go:embed templates/*
var embedded embed.FS
var templateFS, _ = fs.Sub(embedded, "templates")
</code></pre>
<p><code>//go:embed templates/*</code> tells the Go compiler to embed files from the current directory (<code>mailer</code>) into the compiled binary of the Go application. You need this to access the HTML template files from the Go application. <code>templateFS</code> will be used to access the files in the <code>templates</code> subdirectory.</p>
<p>Create another file in the <code>mailer</code> directory and name it <code>mailer.go</code>. <code>mailer.go</code> will contain code used to parse HTML files to make Go HTML templates and also send emails. Copy the content of the code snippet below into <code>mailer.go</code>:</p>
<pre><code class="language-go">package mailer

import (
	"html/template"
	"io"
)

const (
	welcomeMailKey = "welcome_mail"
)

func setUpTemplates() (map[string]*template.Template, error) {
	templates := make(map[string]*template.Template)

	tmpl := template.New("welcome.html").Delims("((", "))")
	welcomeEmailTmpl, err := tmpl.ParseFS(templateFS, "welcome.html")
	if err != nil {
		return nil, err
	}

	templates[welcomeMailKey] = welcomeEmailTmpl

	return templates, nil
}

type Mailer struct {
	templates map[string]*template.Template
}

// NewMailer creates a new mailer
func NewMailer() (*Mailer, error) {
	tpls, err := setUpTemplates()
	if err != nil {
		return nil, err
	}

	return &amp;Mailer{
		templates: tpls,
	}, nil
}

type WelcomEmailData struct {
	Username string
	Company  string
	Gophers  []string
}

func (mailer *Mailer) WriteWelcomeMail(w io.Writer, data WelcomEmailData) error {
	tmpl := mailer.templates[welcomeMailKey]
	err := tmpl.Execute(w, data)

	return err
}
</code></pre>
<p>In the code snippet above:</p>
<ul>
<li><p><code>setUpTemplates</code> creates a template object, <code>tmpl</code>, and sets its delimiters. <code>tmpl</code> parses <code>welcome.html</code> to convert it to a Go template and stores the template with <code>welcomeEmailTmpl</code> as its identifier. After that, <code>welcomeEmailTmpl</code> is added to the <code>templates</code> map with <code>welcomeMailKey</code> as its key and <code>templates</code> is returned.</p>
</li>
<li><p><code>NewMailer</code> creates and returns a <code>Mailer</code> object which holds the templates map and methods to work with the mail templates.</p>
</li>
<li><p>WriteWelcomeMail is a method on <code>Mailer</code> that's used to execute the welcome email template with real data.</p>
</li>
</ul>
<p>To view the current status of the codebase at this point, visit <a href="https://github.com/orimdominic/freeCodeCamp-go-react-email/tree/04-create-golang-template">freeCodeCamp-go-react-email/04-create-golang-template</a>.</p>
<h3 id="heading-render-the-dynamic-email-in-the-browser">Render the Dynamic Email in the Browser</h3>
<p>In this section, you'll create a simple web server to view the rendered email template containing the dynamic values passed to it.</p>
<p>Replace the content of <code>main.go</code> with the code snippet below:</p>
<pre><code class="language-go">package main

import (
	"fmt"
	"net/http"
	"os"

	pkgMailer "github.com/orimdominic/freeCodeCamp-go-react-email/mailer"
)

func main() {
	mailer, err := pkgMailer.NewMailer()
	if err != nil {
		fmt.Fprint(os.Stderr, err)
		os.Exit(1)
	}

	http.HandleFunc("/mail", func(w http.ResponseWriter, r *http.Request) {
		username := r.URL.Query().Get("username")
		company := r.URL.Query().Get("company")
		gophers := []string{"Tinky Winky", "Dipsy", "Laa-Laa", "Po"}

		err := mailer.WriteWelcomeMail(w, pkgMailer.WelcomEmailData{
			Username: username,
			Company:  company,
			Gophers:  gophers,
		})
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
	})

	port := ":8888"
	err = http.ListenAndServe(port, nil)
	if err != nil {
		fmt.Fprint(os.Stderr, err)
		os.Exit(1)
	}
}
</code></pre>
<p>The code snippet above first creates a mailer object using the <code>NewMailer</code> function from <code>mailer.go</code>. After the error handling, it creates a simple web server running on port <code>8888</code> with a <code>GET /mail</code> route.</p>
<p>The <code>GET /mail</code> route accepts two query parameters: <code>username</code> and <code>company</code>, which will be used as the dynamic data for the email. The result of executing the template with <code>WriteWelcomeMail</code> is written as an HTML response on the browser. You'll use this route to test the functionality of the <code>mailer</code> package.</p>
<p>Before you start the server, you should build and export the React Email templates so that your HTML files always have the most recent changes from React Email templates. Instead of navigating between different directories to build, export and run the server, you can use a Makefile.</p>
<p>Navigate to the terminal of the root directory of the project and create a file called <code>Makefile</code>. Copy and paste the content of the code snippet below into it:</p>
<pre><code class="language-plaintext">run: email-build
	go run cmd/main.go

email-build: mailer/_emails
	npm --prefix mailer/_emails run export
</code></pre>
<p>The <code>run</code> script of the Makefile above builds and exports the React Email templates as HTML to the <code>mailer/templates</code> directory and then starts the Go application. Ensure that <code>Makefile</code> uses hard tabs, not spaces for indentation.</p>
<p>Run <code>make run</code> in the terminal of the root directory of the project and visit <code>http://localhost:8888/mail?username=Nicole&amp;company=GoWorld</code> in the browser. You'll see the email rendered on the browser UI.</p>
<img alt="Go template executed in the browser" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Replace the values of <code>username</code> and <code>company</code> in the URL to test the email with different values.</p>
<p>With this setup, you can integrate the result of executing the template with your mail client and the email recipient will see the email as it's displayed in the browser.</p>
<p>To view the current status of the codebase at this point, visit <a href="https://github.com/orimdominic/freeCodeCamp-go-react-email/tree/05-render-dynamic-email">freeCodeCamp-go-react-email/05-render-dynamic-email</a>.</p>
<h3 id="heading-send-and-test-email-with-go-mail-and-mailhog">Send and Test Email with go-mail and MailHog</h3>
<p>In the previous section, you supplied data to execute your template, but it was rendered in the browser, not an email client. In this section, you'll use go-mail to send the email and MailHog to intercept and view it.</p>
<p>This section is optional. If you don't have MailHog installed locally, you'll need Docker Compose to set it up for this project. Make sure Docker Compose is installed on your computer before proceeding.</p>
<p>In your terminal, navigate to the root directory of the project and run <code>go get github.com/wneessen/go-mail</code> to install go-mail. Create a <code>compose.yml</code> file in the root directory of the project and paste the contents of the code snippet below into it:</p>
<pre><code class="language-yaml">services:
  mailhog:
    image: mailhog/mailhog
    restart: no
    logging:
      driver: "none" # disable saving logs
    ports:
      - 1025:1025 # smtp server
      - 8025:8025 # web ui
</code></pre>
<p>In your terminal, navigate to the project's root directory and run <code>docker compose up</code> to pull and start the MailHog SMTP server. MailHog listens for emails on port <code>1025</code> and exposes a web UI at <code>http://localhost:8025</code> where you can view intercepted emails. Depending on your internet connection, the initial image pull may take a few minutes.</p>
<p>Replace <code>mailer.go</code> with the content of the code snippet below:</p>
<pre><code class="language-go">package mailer

import (
	"html/template"
	"io"

	"github.com/wneessen/go-mail"
)

const (
	welcomeMailKey = "welcome_mail"
    sender = "noreply@localhost.com"
)

func setUpTemplates() (map[string]*template.Template, error) {
	templates := make(map[string]*template.Template)

	tmpl := template.New("welcome.html").Delims("((", "))")
	welcomeEmailTmpl, err := tmpl.ParseFS(templateFS, "welcome.html")
	if err != nil {
		return nil, err
	}

	templates[welcomeMailKey] = welcomeEmailTmpl

	return templates, nil
}

type Mailer struct {
	client    *mail.Client
	templates map[string]*template.Template
}

// NewMailer creates a new mailer
func NewMailer() (*Mailer, error) {
	tpls, err := setUpTemplates()
	if err != nil {
		return nil, err
	}

	c, err := mail.NewClient(
		"localhost",
		mail.WithPort(1025),
		mail.WithTLSPolicy(mail.NoTLS),
	)

	if err != nil {
		return nil, err
	}

	return &amp;Mailer{
		client:    c,
		templates: tpls,
	}, nil
}

type WelcomEmailData struct {
	Username string
	Company  string
	Gophers  []string
}

func (mailer *Mailer) WriteWelcomeMail(w io.Writer, data WelcomEmailData) error {
	tmpl := mailer.templates[welcomeMailKey]
	err := tmpl.Execute(w, data)

	return err
}

func (mailer *Mailer) SendWelcomeMail(to string, data WelcomEmailData) error {
	m := mail.NewMsg()
	m.From(sender)
	m.To(to)
	m.Subject("Welcome to " + data.Company)
	m.SetBodyHTMLTemplate(mailer.templates[welcomeMailKey], data)

	err := mailer.client.DialAndSend(m)
	return err
}
</code></pre>
<p>The new changes to <code>mailer.go</code> include:</p>
<ul>
<li><p>An import of the go-mail</p>
</li>
<li><p>The creation of a <code>sender</code> constant which represents the email of the sender</p>
</li>
<li><p>The creation of a mail client with go-mail</p>
</li>
<li><p>The creation of a <code>SendWelcomeMail</code> method on the <code>mailer</code> struct which creates an email with <code>welcomeEmailTmpl</code>, executes it, and sends it to a receiver.</p>
</li>
</ul>
<p>In <code>main.go</code>, update the <code>GET /mail</code> route to use <code>SendWelcomeMail</code> instead of <code>WriteWelcomeMail</code>. You can use any email address you want. The snippet below uses <code>fcc@go.dev</code>:</p>
<pre><code class="language-go">		err := mailer.SendWelcomeMail("fcc@go.dev", pkgMailer.WelcomEmailData{
			Username: username,
			Company:  company,
			Gophers:  gophers,
		})
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		fmt.Fprint(w, "Email sent")
</code></pre>
<p>Ensure that the mail server is running by visiting <a href="http://localhost:8025">http://localhost:8025</a> in your web browser. In another terminal, from the root directory of the project, run <code>make run</code> to start the server. Test the functionality of the route by visiting <code>http://localhost:8888/mail?username=Nicole&amp;company=GoWorld</code> once again. Next, check the email server by visiting <a href="http://localhost:8025">http://localhost:8025</a>. You should see a UI similar to the one in the screenshot below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/66e28b713f978a0e2cd2b763/3cba1adf-f0e7-4539-8f65-931fef1aa73b.png" alt="MailHog UI for previewing mails" style="display:block;margin:0 auto" width="1005" height="486" loading="lazy">

<p>Click on "Welcome to Helix" to view the email.</p>
<p>To view the current status of the codebase at this point, visit <a href="https://github.com/orimdominic/freeCodeCamp-go-react-email/tree/06-send-email">freeCodeCamp-go-react-email/06-send-email</a>.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>By following along with this tutorial, you have:</p>
<ul>
<li><p>Learned how to create Go email templates from React Email templates</p>
</li>
<li><p>Learned how to use Makefiles to run custom scripts</p>
</li>
<li><p>Previewed your email in the browser and tested it using MailHog</p>
</li>
</ul>
<p>You can now skip the hassle of writing raw HTML email tables or learning a new templating language. With React Email and Go templates, you have a cleaner, more developer-friendly way to build and send beautiful emails.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
