<?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>Tue, 30 Jun 2026 10:21:30 +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 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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>
        
            <item>
                <title>
                    <![CDATA[ A Developer’s Guide to Lazy Loading in React and Next.js ]]>
                </title>
                <description>
                    <![CDATA[ Large JavaScript bundles can slow down your application. When too much code loads at once, users wait longer for the first paint and pages feel less responsive. Search engines may also rank slower sit ]]>
                </description>
                <link>https://www.freecodecamp.org/news/a-developers-guide-to-lazy-loading-in-react-and-nextjs/</link>
                <guid isPermaLink="false">69dea43f91716f3cfb762c99</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ David Aniebo ]]>
                </dc:creator>
                <pubDate>Tue, 14 Apr 2026 20:31:59 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9e6d8733-23e7-4dab-8da2-98fbbc1c44e9.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Large JavaScript bundles can slow down your application. When too much code loads at once, users wait longer for the first paint and pages feel less responsive. Search engines may also rank slower sites lower in results.</p>
<p>Lazy loading helps solve this problem by splitting your code into smaller chunks and loading them only when they are needed</p>
<p>This guide walks you through lazy loading in React and Next.js. By the end, you'll know when to use <code>React.lazy</code>, <code>next/dynamic</code>, and <code>Suspense</code>, and you'll have working examples you can copy and adapt to your own projects.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-lazy-loading">What is Lazy Loading?</a></p>
</li>
<li><p><a href="#heading-prequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-use-reactlazy-for-code-splitting">How to Use React.lazy for Code Splitting</a></p>
</li>
<li><p><a href="#heading-how-to-use-suspense-with-reactlazy">How to Use Suspense with React.lazy</a></p>
</li>
<li><p><a href="#heading-how-to-handle-errors-with-error-boundaries">How to Handle Errors with Error Boundaries</a></p>
</li>
<li><p><a href="#heading-how-to-use-nextdynamic-in-nextjs">How to Use next/dynamic in Next.js</a></p>
</li>
<li><p><a href="#heading-reactlazy-vs-nextdynamic-when-to-use-each">React.lazy vs next/dynamic: When to Use Each</a></p>
</li>
<li><p><a href="#heading-real-world-examples">Real-World Examples</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-lazy-loading">What is Lazy Loading?</h2>
<p>Lazy loading is a performance technique that defers loading code until it's needed. Instead of loading your entire app at once, you split it into smaller chunks. The browser only downloads a chunk when the user navigates to that route or interacts with that feature.</p>
<p>Benefits include:</p>
<ul>
<li><p><strong>Faster initial load</strong>: Smaller first bundle means quicker time to interactive</p>
</li>
<li><p><strong>Better Core Web Vitals</strong>: Improves Largest Contentful Paint and Total Blocking Time</p>
</li>
<li><p><strong>Lower bandwidth</strong>: Users only download what they use</p>
</li>
</ul>
<p>In React, you achieve this with dynamic imports and <code>React.lazy()</code> or Next.js’s <code>next/dynamic</code>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you follow along, you should have:</p>
<ul>
<li><p>Basic familiarity with React (components, hooks, state)</p>
</li>
<li><p>Node.js installed (version 18 or later recommended)</p>
</li>
<li><p>A React app (Create React App or Vite) or a Next.js app (for the Next.js examples)</p>
</li>
</ul>
<p>For the React examples, you can use Create React App or Vite. For the Next.js examples, use the App Router (Next.js 13 or later).</p>
<h2 id="heading-how-to-use-reactlazy-for-code-splitting">How to Use <code>React.lazy</code> for Code Splitting</h2>
<p><code>React.lazy()</code> lets you define a component as a dynamic import. React will load that component only when it's first rendered.</p>
<p><code>React.lazy()</code> expects a function that returns a dynamic <code>import()</code>. The imported module must use a default export.</p>
<p>Here's a basic example:</p>
<pre><code class="language-jsx">import { lazy } from 'react';

const HeavyChart = lazy(() =&gt; import('./HeavyChart'));
const AdminDashboard = lazy(() =&gt; import('./AdminDashboard'));

function App() {
  return (
    &lt;div&gt;
      &lt;h1&gt;My App&lt;/h1&gt;
      &lt;HeavyChart /&gt;
      &lt;AdminDashboard /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>If you use named exports, you can map them to a default export:</p>
<pre><code class="language-jsx">const ComponentWithNamedExport = lazy(() =&gt;
  import('./MyComponent').then((module) =&gt; ({
    default: module.NamedComponent,
  }))
);
</code></pre>
<p>You can also name chunks for easier debugging in the browser:</p>
<pre><code class="language-jsx">const HeavyChart = lazy(() =&gt;
  import(/* webpackChunkName: "heavy-chart" */ './HeavyChart')
);
</code></pre>
<p><code>React.lazy()</code> alone isn't enough. You must wrap lazy components in <code>Suspense</code> so React knows what to show while they load.</p>
<h2 id="heading-how-to-use-suspense-with-reactlazy">How to Use <code>Suspense</code> with <code>React.lazy</code></h2>
<p><code>Suspense</code> is a React component that shows a fallback UI while its children are loading. It works with <code>React.lazy()</code> to handle the loading state of dynamically imported components.</p>
<p>Wrap your lazy components in <code>Suspense</code> and provide a <code>fallback</code> prop:</p>
<pre><code class="language-jsx">import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() =&gt; import('./HeavyChart'));
const AdminDashboard = lazy(() =&gt; import('./AdminDashboard'));

function App() {
  return (
    &lt;div&gt;
      &lt;h1&gt;My App&lt;/h1&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading chart...&lt;/div&gt;}&gt;
        &lt;HeavyChart /&gt;
      &lt;/Suspense&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading dashboard...&lt;/div&gt;}&gt;
        &lt;AdminDashboard /&gt;
      &lt;/Suspense&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>You can use a single <code>Suspense</code> boundary for multiple lazy components:</p>
<pre><code class="language-jsx">&lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
  &lt;HeavyChart /&gt;
  &lt;AdminDashboard /&gt;
&lt;/Suspense&gt;
</code></pre>
<p>A more polished fallback improves perceived performance:</p>
<pre><code class="language-jsx">function LoadingSpinner() {
  return (
    &lt;div className="loading-container"&gt;
      &lt;div className="spinner" /&gt;
      &lt;p&gt;Loading...&lt;/p&gt;
    &lt;/div&gt;
  );
}

&lt;Suspense fallback={&lt;LoadingSpinner /&gt;}&gt;
  &lt;HeavyChart /&gt;
&lt;/Suspense&gt;
</code></pre>
<h2 id="heading-how-to-handle-errors-with-error-boundaries">How to Handle Errors with Error Boundaries</h2>
<p><code>React.lazy()</code> and <code>Suspense</code> don't handle loading errors (for example, network failures or missing chunks). For that, you need an Error Boundary.</p>
<p>Error Boundaries are class components that use <code>componentDidCatch</code> or <code>static getDerivedStateFromError</code> to catch errors in their child tree and render a fallback UI.</p>
<p>Here is a simple Error Boundary:</p>
<pre><code class="language-jsx">import { Component } from 'react';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || &lt;div&gt;Something went wrong.&lt;/div&gt;;
    }
    return this.props.children;
  }
}
</code></pre>
<p>Wrap your <code>Suspense</code> boundary with an Error Boundary:</p>
<pre><code class="language-jsx">import { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';

const HeavyChart = lazy(() =&gt; import('./HeavyChart'));

function App() {
  return (
    &lt;ErrorBoundary fallback={&lt;div&gt;Failed to load chart. Please try again.&lt;/div&gt;}&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading chart...&lt;/div&gt;}&gt;
        &lt;HeavyChart /&gt;
      &lt;/Suspense&gt;
    &lt;/ErrorBoundary&gt;
  );
}
</code></pre>
<p>If the chunk fails to load, the Error Boundary catches it and shows your fallback instead of a blank screen or unhandled error.</p>
<h2 id="heading-how-to-use-nextdynamic-in-nextjs">How to Use <code>next/dynamic</code> in Next.js</h2>
<p>Next.js provides <code>next/dynamic</code>, which wraps <code>React.lazy()</code> and <code>Suspense</code> and adds options tailored for Next.js (including Server-Side Rendering).</p>
<p>Basic usage:</p>
<pre><code class="language-jsx">'use client';

import dynamic from 'next/dynamic';

const ComponentA = dynamic(() =&gt; import('../components/A'));
const ComponentB = dynamic(() =&gt; import('../components/B'));

export default function Page() {
  return (
    &lt;div&gt;
      &lt;ComponentA /&gt;
      &lt;ComponentB /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-custom-loading-ui">Custom Loading UI</h3>
<p>Use the <code>loading</code> option to show a placeholder while the component loads:</p>
<pre><code class="language-jsx">const HeavyChart = dynamic(() =&gt; import('../components/HeavyChart'), {
  loading: () =&gt; &lt;p&gt;Loading chart...&lt;/p&gt;,
});
</code></pre>
<h3 id="heading-disable-server-side-rendering">Disable Server-Side Rendering</h3>
<p>For components that must run only on the client (for example, those using <code>window</code> or browser-only APIs), set <code>ssr: false</code>:</p>
<pre><code class="language-jsx">const ClientOnlyMap = dynamic(() =&gt; import('../components/Map'), {
  ssr: false,
  loading: () =&gt; &lt;p&gt;Loading map...&lt;/p&gt;,
});
</code></pre>
<p>Note: <code>ssr: false</code> works only for Client Components. Use it inside a <code>'use client'</code> file.</p>
<h3 id="heading-load-on-demand">Load on Demand</h3>
<p>You can load a component only when a condition is met:</p>
<pre><code class="language-jsx">'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';

const Modal = dynamic(() =&gt; import('../components/Modal'), {
  loading: () =&gt; &lt;p&gt;Opening modal...&lt;/p&gt;,
});

export default function Page() {
  const [showModal, setShowModal] = useState(false);

  return (
    &lt;div&gt;
      &lt;button onClick={() =&gt; setShowModal(true)}&gt;Open Modal&lt;/button&gt;
      {showModal &amp;&amp; &lt;Modal onClose={() =&gt; setShowModal(false)} /&gt;}
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-named-exports">Named Exports</h3>
<p>For named exports, return the component from the dynamic import:</p>
<pre><code class="language-jsx">const Hello = dynamic(() =&gt;
  import('../components/hello').then((mod) =&gt; mod.Hello)
);
</code></pre>
<h3 id="heading-using-suspense-with-nextdynamic">Using Suspense with next/dynamic</h3>
<p>In React 18+, you can use <code>suspense: true</code> to rely on a parent <code>Suspense</code> boundary instead of the <code>loading</code> option:</p>
<pre><code class="language-jsx">const HeavyChart = dynamic(() =&gt; import('../components/HeavyChart'), {
  suspense: true,
});

// In your component:
&lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
  &lt;HeavyChart /&gt;
&lt;/Suspense&gt;
</code></pre>
<p>Important: When using <code>suspense: true</code>, you can't use <code>ssr: false</code> or the <code>loading</code> option. Use the <code>Suspense</code> fallback instead.</p>
<h2 id="heading-reactlazy-vs-nextdynamic-when-to-use-each"><code>React.lazy</code> vs <code>next/dynamic</code>: When to Use Each</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>React.lazy + Suspense</th>
<th>next/dynamic</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Framework</strong></td>
<td>Any React app (Create React App, Vite, etc.)</td>
<td>Next.js only</td>
</tr>
<tr>
<td><strong>Server-Side Rendering</strong></td>
<td>Not supported</td>
<td>Supported by default</td>
</tr>
<tr>
<td><strong>Disable SSR</strong></td>
<td>N/A</td>
<td><code>ssr: false</code> option</td>
</tr>
<tr>
<td><strong>Loading UI</strong></td>
<td><code>Suspense</code> fallback prop</td>
<td>Built-in <code>loading</code> option</td>
</tr>
<tr>
<td><strong>Error handling</strong></td>
<td>Requires Error Boundary</td>
<td>Requires Error Boundary</td>
</tr>
<tr>
<td><strong>Named exports</strong></td>
<td>Manual <code>.then()</code> mapping</td>
<td>Same <code>.then()</code> pattern</td>
</tr>
<tr>
<td><strong>Suspense mode</strong></td>
<td>Always uses Suspense</td>
<td>Optional via <code>suspense: true</code></td>
</tr>
</tbody></table>
<h3 id="heading-when-to-use-reactlazy">When to Use <code>React.lazy</code></h3>
<ul>
<li><p>You're building a <strong>pure React app</strong> (no Next.js)</p>
</li>
<li><p>You use Create React App, Vite, or a custom Webpack setup</p>
</li>
<li><p>You don't need Server-Side Rendering</p>
</li>
<li><p>You want a simple, framework-agnostic approach</p>
</li>
</ul>
<h3 id="heading-when-to-use-nextdynamic">When to <code>Use next/dynamic</code></h3>
<ul>
<li><p>You're building a <strong>Next.js app</strong></p>
</li>
<li><p>You need SSR for some components and want to disable it for others</p>
</li>
<li><p>You want built-in loading placeholders without manually adding <code>Suspense</code></p>
</li>
<li><p>You want Next.js-specific optimizations and defaults</p>
</li>
</ul>
<h2 id="heading-real-world-examples">Real-World Examples</h2>
<h3 id="heading-example-1-route-based-code-splitting-in-react">Example 1: Route-Based Code Splitting in React</h3>
<p>Split your app by route so each page loads only when the user navigates to it:</p>
<pre><code class="language-jsx">// App.jsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import ErrorBoundary from './ErrorBoundary';

const Home = lazy(() =&gt; import('./pages/Home'));
const Dashboard = lazy(() =&gt; import('./pages/Dashboard'));
const Settings = lazy(() =&gt; import('./pages/Settings'));

function App() {
  return (
    &lt;BrowserRouter&gt;
      &lt;ErrorBoundary fallback={&lt;div&gt;Failed to load page.&lt;/div&gt;}&gt;
        &lt;Suspense fallback={&lt;div&gt;Loading page...&lt;/div&gt;}&gt;
          &lt;Routes&gt;
            &lt;Route path="/" element={&lt;Home /&gt;} /&gt;
            &lt;Route path="/dashboard" element={&lt;Dashboard /&gt;} /&gt;
            &lt;Route path="/settings" element={&lt;Settings /&gt;} /&gt;
          &lt;/Routes&gt;
        &lt;/Suspense&gt;
      &lt;/ErrorBoundary&gt;
    &lt;/BrowserRouter&gt;
  );
}
</code></pre>
<h3 id="heading-example-2-lazy-loading-a-heavy-chart-library-in-nextjs">Example 2: Lazy Loading a Heavy Chart Library in Next.js</h3>
<p>Defer loading a chart library until the user opens the analytics section:</p>
<pre><code class="language-jsx">// app/analytics/page.jsx
'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';

const Chart = dynamic(() =&gt; import('../components/Chart'), {
  ssr: false,
  loading: () =&gt; (
    &lt;div className="chart-skeleton"&gt;
      &lt;div className="skeleton-bar" /&gt;
      &lt;div className="skeleton-bar" /&gt;
      &lt;div className="skeleton-bar" /&gt;
    &lt;/div&gt;
  ),
});

export default function AnalyticsPage() {
  const [showChart, setShowChart] = useState(false);

  return (
    &lt;div&gt;
      &lt;h1&gt;Analytics&lt;/h1&gt;
      &lt;button onClick={() =&gt; setShowChart(true)}&gt;Load Chart&lt;/button&gt;
      {showChart &amp;&amp; &lt;Chart /&gt;}
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-example-3-lazy-loading-a-modal">Example 3: Lazy Loading a Modal</h3>
<p>Load a modal component only when the user clicks to open it:</p>
<pre><code class="language-jsx">// React (with React.lazy)
import { lazy, Suspense, useState } from 'react';

const Modal = lazy(() =&gt; import('./Modal'));

function ProductPage() {
  const [showModal, setShowModal] = useState(false);

  return (
    &lt;div&gt;
      &lt;button onClick={() =&gt; setShowModal(true)}&gt;Add to Cart&lt;/button&gt;
      {showModal &amp;&amp; (
        &lt;Suspense fallback={null}&gt;
          &lt;Modal onClose={() =&gt; setShowModal(false)} /&gt;
        &lt;/Suspense&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<pre><code class="language-jsx">// Next.js (with next/dynamic)
'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';

const Modal = dynamic(() =&gt; import('./Modal'), {
  loading: () =&gt; null,
});

export default function ProductPage() {
  const [showModal, setShowModal] = useState(false);

  return (
    &lt;div&gt;
      &lt;button onClick={() =&gt; setShowModal(true)}&gt;Add to Cart&lt;/button&gt;
      {showModal &amp;&amp; &lt;Modal onClose={() =&gt; setShowModal(false)} /&gt;}
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-example-4-lazy-loading-external-libraries">Example 4: Lazy Loading External Libraries</h3>
<p>Load a library only when the user needs it (for example, when they start typing in a search box):</p>
<pre><code class="language-jsx">'use client';

import { useState } from 'react';

const names = ['Alice', 'Bob', 'Charlie', 'Diana'];

export default function SearchPage() {
  const [results, setResults] = useState([]);
  const [query, setQuery] = useState('');

  const handleSearch = async (value) =&gt; {
    setQuery(value);
    if (!value) {
      setResults([]);
      return;
    }
    // Load fuse.js only when user searches
    const Fuse = (await import('fuse.js')).default;
    const fuse = new Fuse(names);
    setResults(fuse.search(value));
  };

  return (
    &lt;div&gt;
      &lt;input
        type="text"
        placeholder="Search..."
        value={query}
        onChange={(e) =&gt; handleSearch(e.target.value)}
      /&gt;
      &lt;ul&gt;
        {results.map((result) =&gt; (
          &lt;li key={result.refIndex}&gt;{result.item}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Lazy loading improves performance by splitting your bundle and loading code only when needed. Here's what you learned:</p>
<ul>
<li><p><strong>React.lazy()</strong> – Use in plain React apps for code splitting. It requires a default export and works with dynamic <code>import()</code>.</p>
</li>
<li><p><strong>Suspense</strong> – Wrap lazy components in <code>Suspense</code> and provide a <code>fallback</code> for the loading state.</p>
</li>
<li><p><strong>Error Boundaries</strong> – Use them to catch chunk load failures and show a friendly error UI.</p>
</li>
<li><p><strong>next/dynamic</strong> – Use in Next.js for the same benefits plus SSR control and built-in loading options.</p>
</li>
</ul>
<p>Choose <code>React.lazy</code> for React-only projects and <code>next/dynamic</code> for Next.js. Combine them with <code>Suspense</code> and Error Boundaries for a solid lazy-loading setup.</p>
<p>Start by identifying your heaviest components (charts, modals, admin panels) and lazy load them. Measure your bundle size and Core Web Vitals before and after to see the impact.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Fashion App That Helps You Organize Your Wardrobe  ]]>
                </title>
                <description>
                    <![CDATA[ I used to spend too long deciding what to wear, even when my closet was full. That frustration made the problem feel very clear to me: it was not about having fewer clothes. It was about having better ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-fashion-app-to-organize-your-wardrobe/</link>
                <guid isPermaLink="false">69de6abf91716f3cfb5448a1</guid>
                
                    <category>
                        <![CDATA[ webdev ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ full stack ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ MathJax ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mokshita V P ]]>
                </dc:creator>
                <pubDate>Tue, 14 Apr 2026 16:26:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bf593ff6-6de8-4b30-ab0a-700c3410ccb1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I used to spend too long deciding what to wear, even when my closet was full.</p>
<p>That frustration made the problem feel very clear to me: it was not about having fewer clothes. It was about having better organization, better visibility, and better guidance when making outfit decisions.</p>
<p>So I built a fashion web app that helps users organize their wardrobe, get outfit suggestions, evaluate shopping decisions, and improve recommendations over time using feedback.</p>
<p>In this article, I’ll walk through what the app does, how I built it, the decisions I made along the way, and the challenges that shaped the final result.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-table-of-contents">Table of Contents</a></p>
</li>
<li><p><a href="#heading-what-the-app-does">What the App Does</a></p>
</li>
<li><p><a href="#heading-why-i-built-it">Why I Built It</a></p>
</li>
<li><p><a href="#heading-tech-stack">Tech Stack</a></p>
</li>
<li><p><a href="#heading-product-walkthrough-what-users-see">Product Walkthrough (What Users See)</a></p>
</li>
<li><p><a href="#heading-how-i-built-it">How I Built It</a></p>
</li>
<li><p><a href="#heading-challenges-i-faced">Challenges I Faced</a></p>
</li>
<li><p><a href="#heading-what-i-learned">What I Learned</a></p>
</li>
<li><p><a href="#heading-what-i-want-to-improve-next">What I Want to Improve Next</a></p>
</li>
<li><p><a href="#heading-future-improvements">Future Improvements</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-the-app-does">What the App Does</h2>
<p>At a high level, the app combines six core capabilities:</p>
<ol>
<li><p>Wardrobe management</p>
</li>
<li><p>Outfit recommendations</p>
</li>
<li><p>Shopping suggestions</p>
</li>
<li><p>Discard recommendations</p>
</li>
<li><p>Feedback and usage tracking</p>
</li>
<li><p>Secure multi-user accounts</p>
</li>
</ol>
<p>Users can upload clothing items, explore suggested outfits, and mark recommendations as helpful or not helpful. They can also rate outfits and track whether items are worn, kept, or discarded.</p>
<p>That feedback becomes structured data for improving future recommendation quality.</p>
<h2 id="heading-why-i-built-it">Why I Built It</h2>
<p>I wanted to create something that felt personal and actually useful. A lot of fashion apps look polished, but they do not always help with everyday decisions. My goal was to build something that could make wardrobe management easier and outfit selection less overwhelming. The app needed to do three things well:</p>
<ul>
<li><p>store each user’s wardrobe data</p>
</li>
<li><p>personalize recommendations</p>
</li>
<li><p>learn from user feedback over time .</p>
</li>
</ul>
<p>That feedback loop mattered to me because it makes the app feel more alive instead of static.</p>
<h2 id="heading-tech-stack">Tech Stack</h2>
<p>Here are the tools I used to built the app:</p>
<ul>
<li><p>Frontend: React + Vite</p>
</li>
<li><p>Backend: FastAPI</p>
</li>
<li><p>Database: SQLite (local development)</p>
</li>
<li><p>Background jobs: Celery + Redis</p>
</li>
<li><p>Authentication: JWT (access + refresh token flow)</p>
</li>
<li><p>Deployment support: Docker and GitHub Codespaces</p>
</li>
</ul>
<p>This ended up giving me a pretty modular setup, which helped a lot as features started increasing: fast frontend iteration, clean API boundaries, and room to evolve recommendations separately from UI.</p>
<h2 id="heading-product-walkthrough-what-users-see">Product Walkthrough (What Users See)</h2>
<h3 id="heading-1-onboarding-and-account-setup">1. Onboarding and Account Setup</h3>
<p>To start using the app, a user needs to register, verify their email, and complete some profile basics.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68ab1274684dc97382d342ea/1ff4fb0d-dc97-4088-b720-db917b53ba5b.png" alt="Onboarding screen showing account creation, email verification, and profile fields for body shape, height, weight, and style preferences." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Each account is isolated, so wardrobe history and recommendations stay user-specific.</p>
<p>In this onboarding screen above, you can see account creation, email verification, and profile fields for body shape, height, weight, and style preferences.</p>
<h3 id="heading-2-wardrobe-upload">2. Wardrobe Upload</h3>
<p>Users can upload clothing images .</p>
<img src="https://cdn.hashnode.com/uploads/covers/68ab1274684dc97382d342ea/d69bf10b-b79b-4294-923c-5c9e5840098a.png" alt="Wardrobe upload form showing clothing image analysis results with category, dominant color, secondary color, and pattern details." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Image analysis labels each item and makes it searchable for recommendations. The wardrobe upload form shows image analysis results with category, dominant color, secondary color, and pattern details listed.</p>
<h3 id="heading-3-outfit-recommendations">3. Outfit Recommendations</h3>
<p>Users can request recommendations, then rate outputs.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68ab1274684dc97382d342ea/61527ddf-11e4-4284-92fd-2d0c948ae2db.png" alt="Outfit recommendation dashboard showing ranked outfit cards with feedback and rating actions." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>Above you can see the outfit recommendation dashboard that shows ranked outfit cards with feedback and rating actions. Recommendations are ranked by a weighted scoring model.</p>
<h3 id="heading-4-shopping-and-discard-assistants">4. Shopping and Discard Assistants</h3>
<p>The app evaluates new items against existing wardrobe data and flags low-value wardrobe items that may be worth removing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/68ab1274684dc97382d342ea/88ed83c4-fdba-40e7-ad32-f77bdf21cb4d.png" alt="Shopping and discard analysis screen showing recommendation scores, written reasons, and styling guidance for each item." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>You can see the recommendation scores, written reasons (not just a binary decision), and styling guidance for each item above. It also features a "how to style it" incase the user still wants to keep the item.</p>
<h2 id="heading-how-i-built-it">How I Built It</h2>
<h3 id="heading-1-frontend-setup-react-vite">1. Frontend Setup (React + Vite)</h3>
<p>I used React + Vite because I wanted fast iteration and a clean component structure.</p>
<p>The frontend is split into feature areas like onboarding, wardrobe management, outfits, shopping, and discarded-item suggestions. I also keep API calls in a service layer so the UI components stay focused on rendering and interaction.</p>
<p>The snippet below is a simplified example of the API service pattern used in the app. It is not meant to be copy-pasted as-is, but it shows the same structure the frontend uses when talking to the backend.</p>
<p>Example API client pattern:</p>
<pre><code class="language-javascript">export async function getOutfitRecommendations(userId, params = {}) {
  const query = new URLSearchParams(params).toString();
  const url = `/users/\({userId}/outfits/recommend\){query ? `?${query}` : ""}`;

  const response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${localStorage.getItem("access_token")}`,
    },
  });

  if (!response.ok) {
    throw new Error("Failed to fetch outfit recommendations");
  }

  return response.json();
}
</code></pre>
<p>Here's what's happening in that snippet:</p>
<ul>
<li><p><code>URLSearchParams</code> builds optional query strings like <code>occasion</code>, <code>season</code>, or <code>limit</code>.</p>
</li>
<li><p>The request path is user-scoped, which keeps each user’s recommendations isolated.</p>
</li>
<li><p>The <code>Authorization</code> header sends the access token so the backend can verify the session.</p>
</li>
<li><p>The response is checked before parsing so the UI can surface a useful error if the request fails.</p>
</li>
</ul>
<p>This pattern kept the frontend simple and reusable as the number of API calls grew.</p>
<h3 id="heading-2-backend-architecture-with-fastapi">2. Backend Architecture with FastAPI</h3>
<p>The backend is organized around clear route groups:</p>
<ul>
<li><p>auth routes for register, login, refresh, logout, and sessions</p>
</li>
<li><p>user analysis routes</p>
</li>
<li><p>wardrobe CRUD routes</p>
</li>
<li><p>recommendation routes for outfits, shopping, and discard analysis</p>
</li>
<li><p>feedback routes for ratings and helpfulness signals</p>
</li>
</ul>
<p>One of the most important design choices was enforcing ownership checks on user-scoped resources. That prevented one user from accessing another user’s wardrobe or feedback data.</p>
<p>The backend snippet below is another simplified example from the app’s route layer. It shows the request validation and orchestration logic, while the actual scoring work stays in the recommendation service.</p>
<pre><code class="language-python">@app.get("/users/{user_id}/outfits/recommend")
def recommend_outfits(user_id: int, occasion: str | None = None, season: str | None = None, limit: int = 10):
    user = get_user_or_404(user_id)
    wardrobe_items = get_user_wardrobe(user_id)

    if len(wardrobe_items) &lt; 2:
        raise HTTPException(status_code=400, detail="Not enough wardrobe items")

    recommendations = outfit_generator.generate_outfit_recommendations(
        wardrobe_items=wardrobe_items,
        body_shape=user.body_shape,
        undertone=user.undertone,
        occasion=occasion,
        season=season,
        top_k=limit,
    )

    return {"user_id": user_id, "recommendations": recommendations}
</code></pre>
<p>Here's how to read that code:</p>
<ul>
<li><p><code>get_user_or_404</code> loads the profile data needed for personalization.</p>
</li>
<li><p><code>get_user_wardrobe</code> fetches only the current user’s items.</p>
</li>
<li><p>The minimum wardrobe check prevents the recommendation logic from running on incomplete data.</p>
</li>
<li><p><code>generate_outfit_recommendations</code> handles the scoring logic separately, which keeps the route handler small and easier to test.</p>
</li>
<li><p>The response returns the results in a shape the frontend can consume directly.</p>
</li>
</ul>
<p>That separation helped keep the API layer readable while the recommendation logic stayed isolated in its own service.</p>
<h3 id="heading-3-recommendation-logic">3. Recommendation Logic</h3>
<p>I intentionally started with deterministic rules before introducing heavy ML. That made behavior easier to debug and explain.</p>
<p>The outfit recommender scores combinations using weighted signals:</p>
<p>$$\text{outfit score} = 0.4 \cdot \text{color harmony} + 0.4 \cdot \text{body-shape fit} + 0.2 \cdot \text{undertone fit}$$</p>
<p>The snippet below is a simplified example from the recommendation engine. It shows how the app combines multiple signals into a single score:</p>
<pre><code class="language-python">def score_outfit(combo, user_context):
    color_score = color_harmony.score(combo)
    shape_score = body_shape_rules.score(combo, user_context.body_shape)
    undertone_score = undertone_rules.score(combo, user_context.undertone)

    total = 0.4 * color_score + 0.4 * shape_score + 0.2 * undertone_score
    return round(total, 3)
</code></pre>
<p>The logic behind this approach is straightforward:</p>
<ul>
<li><p>color harmony helps the outfit feel visually coherent</p>
</li>
<li><p>body-shape scoring helps the outfit feel flattering</p>
</li>
<li><p>undertone scoring helps the colors work better with the user’s profile</p>
</li>
</ul>
<p>I used a similar structure for discard recommendations and shopping suggestions, but with different factors and thresholds.</p>
<h3 id="heading-4-authentication-and-secure-multi-user-design">4. Authentication and Secure Multi-user Design</h3>
<p>Security was one of the most important parts of this build.</p>
<p>I implemented:</p>
<ul>
<li><p>short-lived access tokens</p>
</li>
<li><p>refresh tokens with JTI tracking</p>
</li>
<li><p>token rotation on refresh</p>
</li>
<li><p>session revocation (single session and all sessions)</p>
</li>
<li><p>email verification and password reset flows</p>
</li>
</ul>
<p>The snippet below is a simplified example of the refresh-token lifecycle used in the app. It shows the important control points rather than every helper function:</p>
<pre><code class="language-python">def refresh_access_token(refresh_token: str):
    payload = decode_jwt(refresh_token)
    jti = payload["jti"]

    token_record = db.get_refresh_token(jti)
    if not token_record or token_record.revoked:
        raise AuthError("Invalid refresh token")

    new_refresh, new_jti = issue_refresh_token(payload["sub"])
    token_record.revoked = True
    token_record.replaced_by_jti = new_jti

    new_access = issue_access_token(payload["sub"])
    return {"access_token": new_access, "refresh_token": new_refresh}
</code></pre>
<p>What this code is doing:</p>
<ul>
<li><p>It decodes the refresh token and looks up its JTI in the database.</p>
</li>
<li><p>It rejects reused or revoked sessions, which helps prevent replay attacks.</p>
</li>
<li><p>It rotates the refresh token instead of reusing it.</p>
</li>
<li><p>It issues a fresh access token so the session stays valid without forcing the user to log in again.</p>
</li>
</ul>
<p>This design made multi-device sessions safer and gave me server-side control over logout behavior.</p>
<h3 id="heading-5-background-jobs-for-long-running-operations">5. Background Jobs for Long-running Operations</h3>
<p>Image analysis can be expensive, especially when the app needs to classify clothing, analyze colors, and estimate body-shape-related signals. To keep the request path responsive, I added Celery + Redis support for background tasks.</p>
<p>That gave the app two modes:</p>
<ul>
<li><p>synchronous processing for simpler local development</p>
</li>
<li><p>queued processing for heavier or slower jobs</p>
</li>
</ul>
<p>That tradeoff mattered because it let me keep the developer experience simple without blocking the app during more expensive work.</p>
<h3 id="heading-6-data-model-and-feedback-capture">6. Data Model and Feedback Capture</h3>
<p>A recommendation system only improves if it captures the right signals.</p>
<p>So I added dedicated feedback tables for:</p>
<ul>
<li><p>outfit ratings (1-5 + optional comments)</p>
</li>
<li><p>recommendation helpful/unhelpful feedback</p>
</li>
<li><p>item usage actions (worn/kept/discarded)</p>
</li>
</ul>
<p>Here is the shape of one of those models:</p>
<pre><code class="language-python">class RecommendationFeedback(Base):
    __tablename__ = "recommendation_feedback"

    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
    recommendation_type = Column(String(50), nullable=False)
    recommendation_id = Column(Integer, nullable=False)
    helpful = Column(Boolean, nullable=False)
    created_at = Column(DateTime, default=datetime.utcnow)
</code></pre>
<p>How to read this model:</p>
<ul>
<li><p><code>user_id</code> ties feedback to the person who gave it.</p>
</li>
<li><p><code>recommendation_type</code> tells me whether the feedback belongs to outfits, shopping, or discard suggestions.</p>
</li>
<li><p><code>recommendation_id</code> identifies the exact recommendation.</p>
</li>
<li><p><code>helpful</code> stores the user’s direct response.</p>
</li>
<li><p><code>created_at</code> makes it possible to analyze feedback trends over time.</p>
</li>
</ul>
<p>This part of the system gives the app a real learning foundation, even though the feedback-to-model-update loop is still a future improvement.</p>
<h2 id="heading-challenges-i-faced">Challenges I Faced</h2>
<p>This was the section that taught me the most.</p>
<h3 id="heading-1-image-heavy-endpoints-were-slower-than-i-wanted">1. Image-heavy endpoints were slower than I wanted</h3>
<p>The analyze and wardrobe upload flows were doing a lot of work at once: image validation, classification, color extraction, storage, and database writes.</p>
<p>At first, that made the request flow feel heavier than it should have.</p>
<p>What I changed:</p>
<ul>
<li><p>I bounded concurrent image jobs so the app wouldn't try to do too much at once.</p>
</li>
<li><p>I separated slower jobs into background processing where possible.</p>
</li>
<li><p>I used load-test results to confirm which endpoints were actually expensive.</p>
</li>
</ul>
<p>The practical effect was that heavy image requests stopped competing with each other so aggressively. Instead of letting many expensive tasks pile up inside the same request cycle, I limited the active work and pushed slower operations into the queue when needed.</p>
<p>Why this fixed it:</p>
<ul>
<li><p>Bounding concurrency prevented the system from overloading CPU-bound tasks.</p>
</li>
<li><p>Moving expensive work into async jobs kept the main request/response cycle more responsive.</p>
</li>
<li><p>Load testing gave me evidence instead of guesswork, so I could tune the system based on real performance behavior.</p>
</li>
</ul>
<p>In other words, I didn't just “optimize” the endpoint in theory. I changed the execution model so expensive analysis could not block every other request behind it.</p>
<h3 id="heading-2-jwt-sessions-needed-real-server-side-control">2. JWT sessions needed real server-side control</h3>
<p>A basic JWT setup is easy to get working, but it becomes less useful if you cannot revoke sessions or manage multiple devices cleanly.</p>
<p>What I changed:</p>
<ul>
<li><p>I stored refresh tokens in the database.</p>
</li>
<li><p>I tracked token JTI values.</p>
</li>
<li><p>I rotated refresh tokens when users refreshed their session.</p>
</li>
<li><p>I added endpoints for logging out a single session or all sessions.</p>
</li>
</ul>
<p>The important shift here was moving from “token exists, therefore session is valid” to “token exists, matches the database record, and has not been revoked or replaced.” That gave the server the authority to invalidate old sessions immediately.</p>
<p>Why this fixed it:</p>
<ul>
<li><p>Server-side token tracking made revocation possible.</p>
</li>
<li><p>Rotation reduced the chance of token reuse.</p>
</li>
<li><p>Session management became visible to the user, which made the app feel more trustworthy.</p>
</li>
</ul>
<p>This is what made logout-all and multi-device management work in a real way instead of just being cosmetic UI actions.</p>
<h3 id="heading-3-user-data-isolation-had-to-be-explicit">3. User data isolation had to be explicit</h3>
<p>Because this is a multi-user app, I had to be careful that one account could never accidentally see another account’s wardrobe data.</p>
<p>What I changed:</p>
<ul>
<li><p>I added ownership checks to user-scoped routes.</p>
</li>
<li><p>I kept all wardrobe and feedback queries filtered by <code>user_id</code>.</p>
</li>
<li><p>I used encrypted image storage instead of exposing raw paths.</p>
</li>
</ul>
<p>In practice, this meant every route had to ask the same question: “Does this user own the resource they are trying to access?” If the answer was no, the request stopped immediately.</p>
<p>Why this fixed it:</p>
<ul>
<li><p>Ownership checks made data access rules explicit.</p>
</li>
<li><p>User-filtered queries prevented accidental cross-account reads.</p>
</li>
<li><p>Encrypted storage improved privacy and reduced the risk of exposing image data directly.</p>
</li>
</ul>
<p>That combination is what kept wardrobe data, feedback history, and images separated correctly across accounts.</p>
<h3 id="heading-4-docker-made-the-project-easier-to-share-but-only-after-the-stack-was-organized">4. Docker made the project easier to share, but only after the stack was organized</h3>
<p>The app includes the frontend, backend, Redis, Celery worker, and Celery Beat, so the first challenge was making the setup feel reproducible instead of fragile.</p>
<p>What I changed:</p>
<ul>
<li><p>I defined the stack in Docker Compose.</p>
</li>
<li><p>I documented the required environment variables.</p>
</li>
<li><p>I kept the dev stack aligned with how the app runs in practice.</p>
</li>
</ul>
<p>This removed a lot of setup ambiguity. Instead of asking someone to manually figure out how the frontend, backend, Redis, and workers fit together, I made the stack describe itself.</p>
<p>Why this fixed it:</p>
<ul>
<li><p>Docker let contributors start the project with fewer manual steps.</p>
</li>
<li><p>Clear environment configuration reduced setup mistakes.</p>
</li>
<li><p>Matching the stack to the architecture made the app easier to understand and test.</p>
</li>
</ul>
<p>That was important because the app depends on several moving parts, and the simplest way to make the project approachable was to make startup behavior predictable.</p>
<h2 id="heading-what-i-learned">What I Learned</h2>
<p>This project taught me a few important lessons:</p>
<ul>
<li><p>Small features become much more valuable when they work together.</p>
</li>
<li><p>Feedback data is one of the strongest signals for improving recommendations.</p>
</li>
<li><p>Clean data modeling matters a lot when multiple users are involved.</p>
</li>
<li><p>Docker and clear setup instructions make a project much easier for other people to try.</p>
</li>
</ul>
<p>I also learned that a project does not need to be huge to be useful. A focused app that solves one problem well can still feel meaningful.</p>
<h2 id="heading-what-i-want-to-improve-next">What I Want to Improve Next</h2>
<p>My roadmap from here:</p>
<ol>
<li><p>Integrate feedback directly into ranking updates</p>
</li>
<li><p>Add visual analytics for recommendation quality trends</p>
</li>
<li><p>Improve mobile UX parity</p>
</li>
<li><p>Deploy with persistent cloud storage and production database defaults</p>
</li>
<li><p>Provide a public demo mode for easier evaluation</p>
</li>
</ol>
<h2 id="heading-future-improvements">Future Improvements</h2>
<p>There are still a few things I would like to add later:</p>
<ul>
<li><p>a more advanced recommendation engine</p>
</li>
<li><p>visual analytics for user feedback</p>
</li>
<li><p>better mobile support</p>
</li>
<li><p>live deployment with persistent cloud storage</p>
</li>
<li><p>a public demo mode for easier testing</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>This project began as a personal frustration and turned into a full web application with authentication, wardrobe storage, recommendation logic, and feedback infrastructure.</p>
<p>The most rewarding part was seeing how practical software decisions, not just flashy UI, can help people make everyday choices faster.</p>
<p>If you want to explore or run the project, <a href="https://github.com/Mokshitavp1/fashion_assistant">check out the repo</a>. You can try the flows and share feedback. I would especially love input on recommendation quality, UX clarity, and what features would make this genuinely useful in daily life.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Admin Dashboard Sidebar with shadcn/ui and Base UI ]]>
                </title>
                <description>
                    <![CDATA[ Admin dashboards are one of the most common real-world UI components you will build as a React developer. At the heart of nearly every dashboard is a sidebar, a persistent navigation panel that organi ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-an-admin-dashboard-sidebar-with-shadcn-ui-and-base-ui/</link>
                <guid isPermaLink="false">69de6a6491716f3cfb542305</guid>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ UI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ baseui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tailwind CSS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Tue, 14 Apr 2026 16:25:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3ce152b1-9a34-4c72-85f0-cabf7d4f3460.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Admin dashboards are one of the most common real-world UI components you will build as a React developer. At the heart of nearly every dashboard is a sidebar, a persistent navigation panel that organizes pages, tools, and features into a clean, scannable structure.</p>
<p>Building a sidebar from scratch involves much more than an <code>&lt;nav&gt;</code> element. You need collapsible submenus, active state tracking across parent and child items, accessible keyboard navigation, a scroll area for long nav lists, and a consistent design system that holds together across screen sizes.</p>
<p>In this tutorial, you'll learn how to build a fully functional, accessible admin dashboard sidebar using shadcn/ui, a collection of beautifully designed, accessible React components, and a pre-built community block from Shadcn Space, which extends shadcn/ui with ready-to-use dashboard UI patterns.</p>
<p>By the end of this tutorial, you'll have a working sidebar that includes:</p>
<ul>
<li><p>Grouped navigation sections with uppercase labels</p>
</li>
<li><p>Collapsible parent menu items with child links</p>
</li>
<li><p>Active state tracking across both parent and child items</p>
</li>
<li><p>A floating sidebar with an independent scroll area</p>
</li>
<li><p>A promotional card pinned inside the sidebar footer</p>
</li>
</ul>
<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-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="#heading-why-shadcnui">Why shadcn/ui?</a></p>
</li>
<li><p><a href="#heading-what-is-shadcn-space">What is Shadcn Space?</a></p>
</li>
<li><p><a href="#heading-how-to-install-the-sidebar-block">How to Install the Sidebar Block</a></p>
</li>
<li><p><a href="#heading-how-to-understand-the-folder-structure">How to Understand the Folder Structure</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-page-layout">How to Build the Page Layout</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-appsidebar-component">How to Build the AppSidebar Component</a></p>
</li>
<li><p><a href="#heading-how-to-define-the-navigation-data">How to Define the Navigation Data</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-navmain-component">How to Build the NavMain Component</a></p>
</li>
<li><p><a href="#heading-how-to-handle-active-states-and-collapsible-menus">How to Handle Active States and Collapsible Menus</a></p>
</li>
<li><p><a href="#heading-how-to-style-the-sidebar">How to Style the Sidebar</a></p>
</li>
<li><p><a href="#heading-live-preview">Live Preview</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you start, make sure you have the following:</p>
<ul>
<li><p>Node.js 18+ installed on your machine</p>
</li>
<li><p>Basic knowledge of React and TypeScript</p>
</li>
<li><p>Familiarity with Tailwind CSS utility classes</p>
</li>
<li><p>A package manager installed (npm, pnpm, yarn, or bun)</p>
</li>
</ul>
<p>You don't need prior experience with shadcn/ui, but it helps to have read through <a href="https://ui.shadcn.com/docs">the official docs</a> at least once.</p>
<h2 id="heading-what-you-will-build"><strong>What You Will Build</strong></h2>
<p>In this article, you'll build a fully functional admin dashboard sidebar with the following features:</p>
<ol>
<li><p><strong>Floating sidebar shell</strong>: a card-style sidebar with rounded corners, a drop shadow, and a configurable width</p>
</li>
<li><p><strong>Grouped navigation</strong>: navigation items organized under section labels like Dashboards, Pages, Apps, and Form Elements</p>
</li>
<li><p><strong>Collapsible submenus</strong>: parent items like Blogs and Shadcn Forms that expand on click to reveal child links</p>
</li>
<li><p><strong>Active state tracking</strong>: visual highlighting of the selected parent and child item at all times</p>
</li>
<li><p><strong>Sidebar toggle</strong>: a trigger button in the page header that opens and closes the sidebar</p>
</li>
<li><p><strong>Promotional card</strong>: a "Get Premium" card at the bottom of the sidebar scroll area</p>
</li>
</ol>
<h2 id="heading-why-shadcnui"><strong>Why shadcn/ui?</strong></h2>
<p><a href="https://ui.shadcn.com/"><strong>shadcn/ui</strong></a> is a collection of beautifully designed, accessible React components built on top of Radix UI and styled with Tailwind CSS.</p>
<p>Instead of installing a traditional component library as a dependency, you copy components directly into your project using a CLI. This gives you full ownership of the code structure and styling. You can read every line, change anything, and the components never break because of a library update you didn't control.</p>
<p>Some key benefits of shadcn/ui include:</p>
<ul>
<li><p>Accessible by default, built on Radix and Base UI primitives</p>
</li>
<li><p>Fully styled with Tailwind CSS utility classes</p>
</li>
<li><p>Zero lock-in: the code lives in your project, not inside <code>node_modules</code></p>
</li>
<li><p>Works with Next.js, React, Astro, Vite, and other frameworks</p>
</li>
<li><p>A growing ecosystem of community-built blocks and registries</p>
</li>
</ul>
<p>The <code>Sidebar, Collapsible, ScrollArea, Card, and Button</code> Components you'll use in this tutorial all come from shadcn/ui.</p>
<h2 id="heading-what-is-shadcn-space"><strong>What is Shadcn Space?</strong></h2>
<p><strong>Shadcn Space</strong> is an open-source library of pre-built <a href="https://shadcnspace.com/blocks"><strong>UI blocks</strong></a> built on top of shadcn/ui. It provides ready-to-use dashboard layouts, sidebars, tables, cards, and other common admin UI patterns so you don't have to assemble them from individual primitives every time.</p>
<p>Each block in Shadcn Space is installable directly into your project using the shadcn CLI. Once installed, the code is yours: you can read it, extend it, and adapt it to your design system without any runtime dependency on Shadcn Space itself.</p>
<p>For this tutorial, you'll use the <code>sidebar-06</code> block (it’s free to use), which is a floating admin sidebar with grouped navigation, collapsible submenus, and an integrated scroll area.</p>
<p>Shadcn Space also provides a companion <a href="https://www.figma.com/community/file/1597967874273587400/shadcn-space-figma-ui-kit"><strong>Figma UI Kit</strong></a> that matches the design system used in the blocks, which is useful if you do design work alongside development.</p>
<p>You can explore the full block library and the getting-started documentation in the <a href="https://shadcnspace.com/docs/getting-started/blocks"><strong>official Shadcn Space docs</strong></a>.</p>
<h3 id="heading-how-to-set-up-the-project">How to Set Up the Project</h3>
<p>Start by creating a new Next.js project if you don't already have one:</p>
<pre><code class="language-javascript">npx shadcn@latest init --preset b0 --base base --template next
</code></pre>
<p>This command:</p>
<ul>
<li><p>Creates a Next.js project</p>
</li>
<li><p>Configures Tailwind CSS</p>
</li>
<li><p>Sets up Base UI as the component foundation</p>
</li>
<li><p>Uses Nova style preset</p>
</li>
<li><p>Configures Lucide icons</p>
</li>
<li><p>Uses Inter font</p>
</li>
<li><p>Applies neutral theme tokens</p>
</li>
</ul>
<p>Follow the prompts to configure your base color, CSS variables, and component output directory. This sets up the <code>components/ui</code> directory and the required Tailwind configuration that all shadcn/ui components depend on.</p>
<p>Once the initialization is complete, your <code>components.json</code> project will be created at the root of your project. This file tells the shadcn CLI where to place components, what path aliases you're using, and which styling configuration to follow.</p>
<p>Add this in <code>components.json</code>:</p>
<pre><code class="language-javascript">{
  "registries": {
    "@shadcn-space": {
      "url": "https://shadcnspace.com/r/{name}.json",
    }
  }
}
</code></pre>
<h2 id="heading-how-to-install-the-sidebar-block"><strong>How to Install the Sidebar Block</strong></h2>
<p>With shadcn/ui initialized, you can now pull in the <code>sidebar-06</code> block from Shadcn Space. While Shadcn Space provides components for both Radix UI and Base UI, this tutorial uses the Base UI version. Run one of the following commands depending on your package manager:</p>
<p><strong>npm:</strong></p>
<pre><code class="language-javascript">npx shadcn@latest add @shadcn-space/sidebar-06
</code></pre>
<p><strong>pnpm:</strong></p>
<pre><code class="language-javascript">pnpm dlx shadcn@latest add @shadcn-space/sidebar-06
</code></pre>
<p><strong>yarn:</strong></p>
<pre><code class="language-javascript">yarn dlx shadcn@latest add @shadcn-space/sidebar-06
</code></pre>
<p><strong>bun:</strong></p>
<pre><code class="language-javascript">bunx --bun shadcn@latest add @shadcn-space/sidebar-06
</code></pre>
<p>This command fetches the block from the Shadcn Space registry and scaffolds all the required component files into your project automatically. It also installs any shadcn/ui primitives the block depends on (such as <code>Sidebar, ScrollArea, Card, Button,</code> and <code>Collapsible</code>) if they aren't already present in your components/ui directory.</p>
<p>You can preview the live block and find the installation command on their <a href="https://shadcnspace.com/blocks/dashboard-ui/sidebars"><strong>shadcn sidebar</strong></a> page.</p>
<h2 id="heading-how-to-understand-the-folder-structure"><strong>How to Understand the Folder Structure</strong></h2>
<p>After installation, your project will contain the following new files:</p>
<pre><code class="language-javascript">app/
  sidebar-06/
    page.tsx                  ← Route entry point
assets/
  logo/
    logo.tsx                  ← Logo component
components/
  shadcn-space/
    blocks/
      sidebar-06/
        app-sidebar.tsx       ← Main sidebar shell
        nav-main.tsx          ← Navigation logic and rendering
</code></pre>
<p>Each file has a clearly defined responsibility:</p>
<ul>
<li><p><code>app/sidebar-06/page.tsx</code>: the route entry point that wires the sidebar into a page layout using SidebarProvider</p>
</li>
<li><p><code>assets/logo/logo.tsx</code>: the logo component rendered in the sidebar header</p>
</li>
<li><p><code>components/shadcn-space/blocks/sidebar-06/app-sidebar.tsx</code>: the main sidebar shell, including the header, scroll area, nav data, and promotional card</p>
</li>
<li><p><code>components/shadcn-space/blocks/sidebar-06/nav-main.tsx</code>: all navigation rendering logic, including section labels, leaf items, collapsible parents, and active state management</p>
</li>
</ul>
<p>You'll work through each of these files in detail in the sections below.</p>
<h2 id="heading-how-to-build-the-page-layout"><strong>How to Build the Page Layout</strong></h2>
<p>Open <code>app/sidebar-06/page.tsx</code>. This file is the entry point for your dashboard page. It uses <code>SidebarProvider</code> to establish sidebar context across the page, and <code>SidebarTrigger</code> to render a toggle button inside the header.</p>
<pre><code class="language-javascript">import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/shadcn-space/blocks/sidebar-06/app-sidebar";

const Page = () =&gt; {
  return (
    &lt;SidebarProvider
      className="p-4 bg-muted"
      style={{ "--sidebar-width": "300px" } as React.CSSProperties}
    &gt;
      &lt;AppSidebar /&gt;

      {/* Main content area */}
      &lt;div className="flex flex-1 flex-col gap-4"&gt;
        &lt;header className="flex h-14 shrink-0 items-center gap-2 rounded-xl bg-background px-4 shadow-sm"&gt;
          &lt;SidebarTrigger className="cursor-pointer" /&gt;
        &lt;/header&gt;
        &lt;main className="flex-1 rounded-xl bg-background" /&gt;
      &lt;/div&gt;
    &lt;/SidebarProvider&gt;
  );
};

export default Page;
</code></pre>
<p>Let's break down the key parts of this layout:</p>
<p><strong>SidebarProvider</strong> wraps everything on the page. It manages the sidebar's open/closed state and passes it down to child components via React context. Any component that needs to read or change the sidebar state, including <code>SidebarTrigger</code> and <code>AppSidebar</code>, must be a descendant of <code>SidebarProvider</code>.</p>
<p><strong>The</strong> <code>--sidebar-width</code> <strong>CSS custom property</strong> controls the rendered width of the sidebar. It's set inline using a type assertion (<code>as React.CSSProperties</code>) because TypeScript doesn't know about this custom property by default. Setting it here rather than in a CSS file keeps the width configurable on a per-page basis.</p>
<p><code>SidebarTrigger</code> is a toggle button component that reads the sidebar open/closed state from the nearest <code>SidebarProvider</code> context and flips it on click. It renders in the header so users always have access to the toggle regardless of scroll position.</p>
<p><code>bg-muted</code> on <code>SidebarProvider</code> creates the light gray outer background that makes the floating sidebar card visually stand out from the page.</p>
<h2 id="heading-how-to-build-the-appsidebar-component"><strong>How to Build the AppSidebar Component</strong></h2>
<p>Open <code>components/shadcn-space/blocks/sidebar-06/app-sidebar.tsx</code>. This component is the main sidebar shell. It composes shadcn/ui's <code>Sidebar</code>, <code>SidebarHeader</code>, and <code>SidebarContent</code> layout primitives and wraps the scrollable navigation area in a <code>ScrollArea</code> component to handle overflow independently.</p>
<pre><code class="language-javascript">"use client";

import {
  Sidebar,
  SidebarContent,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuItem,
} from "@/components/ui/sidebar";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Logo from "@/assets/logo/logo";
import { NavItem, NavMain } from "@/components/shadcn-space/blocks/sidebar-06/nav-main";
import {
  AlignStartVertical,
  PieChart,
  CircleUserRound,
  ClipboardList,
  Notebook,
  NotepadText,
  Table,
  Languages,
  Ticket,
} from "lucide-react";
</code></pre>
<p>The <code>"use client"</code> directive at the top is required because this component uses React state (through <code>NavMain</code>) and event handlers, both of which require the component to run in the browser rather than being server-rendered by Next.js.</p>
<h2 id="heading-how-to-define-the-navigation-data"><strong>How to Define the Navigation Data</strong></h2>
<p>Inside <code>app-sidebar.tsx</code>the navigation structure is defined as a flat array of <code>NavItem</code> objects. Each item belongs to one of three categories:</p>
<ol>
<li><p><strong>A section label</strong> marked with <code>isSection: true</code> and a <code>label</code> string. Renders as an uppercase group heading.</p>
</li>
<li><p><strong>A leaf item</strong> has a <code>title, icon</code>, and <code>href</code>, but no <code>children</code>. Renders as a direct navigation link.</p>
</li>
<li><p><strong>A parent item</strong> has a <code>title, icon</code>, and a <code>children</code> array of sub-items. Renders as a collapsible trigger.</p>
</li>
</ol>
<pre><code class="language-javascript">
export const navData: NavItem[] = [
  // Dashboards Section
  { label: "Dashboards", isSection: true },
  { title: "Analytics", icon: PieChart, href: "#" },
  { title: "CRM Dashboard", icon: ClipboardList, href: "#" },

  // Pages Section
  { label: "Pages", isSection: true },
  { title: "Tables", icon: Table, href: "#" },
  { title: "Forms", icon: ClipboardList, href: "#" },
  { title: "User Profile", icon: CircleUserRound, href: "#" },

  // Apps Section
  { label: "Apps", isSection: true },
  { title: "Notes", icon: Notebook, href: "#" },
  { title: "Tickets", icon: Ticket, href: "#" },
  {
    title: "Blogs",
    icon: Languages,
    children: [
      { title: "Blog Post", href: "#" },
      { title: "Blog Detail", href: "#" },
      { title: "Blog Edit", href: "#" },
      { title: "Blog Create", href: "#" },
      { title: "Manage Blogs", href: "#" },
    ],
  },

  // Form Elements Section
  { label: "Form Elements", isSection: true },
  {
    title: "Shadcn Forms",
    icon: NotepadText,
    children: [
      { title: "Button", href: "#" },
      { title: "Input", href: "#" },
      { title: "Select", href: "#" },
      { title: "Checkbox", href: "#" },
      { title: "Radio", href: "#" },
    ],
  },
  {
    title: "Form layouts",
    icon: AlignStartVertical,
    children: [
      { title: "Forms Horizontal", href: "#" },
      { title: "Forms Vertical", href: "#" },
      { title: "Forms Validation", href: "#" },
      { title: "Forms Examples", href: "#" },
      { title: "Forms Wizard", href: "#" },
    ],
  },
];
</code></pre>
<p>This flat array approach is intentionally simple to maintain. You don't need a nested tree structure because the <code>NavMain</code> component handles the rendering logic for each item type by inspecting each item's shape. Adding a new section, item, or submenu is as straightforward as appending a new object to the array.</p>
<h2 id="heading-how-to-build-the-navmain-component"><strong>How to Build the NavMain Component</strong></h2>
<p>Open <code>components/shadcn-space/blocks/sidebar-06/nav-main.tsx</code>. This file contains all the navigation rendering logic. Start with the type definition and the top-level <code>NavMain</code> function:</p>
<pre><code class="language-javascript">"use client";

import * as React from "react";
import { ChevronRight, LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
  Collapsible,
  CollapsibleTrigger,
  CollapsibleContent,
} from "@/components/ui/collapsible";
import {
  SidebarGroup,
  SidebarGroupLabel,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarMenuSub,
  SidebarMenuSubItem,
  SidebarMenuSubButton,
} from "@/components/ui/sidebar";

export type NavItem = {
  label?: string;
  isSection?: boolean;
  title?: string;
  icon?: LucideIcon;
  href?: string;
  children?: NavItem[];
};

export function NavMain({ items }: { items: NavItem[] }) {
  const [activeParent, setActiveParent] = React.useState&lt;string | null&gt;(
    items.find((i) =&gt; !i.isSection)?.title || null
  );
  const [activeChild, setActiveChild] = React.useState&lt;string | null&gt;(null);

  return (
    &lt;&gt;
      {items.map((item, index) =&gt; (
        &lt;NavMainItem
          key={item.title || item.label || index}
          item={item}
          activeParent={activeParent}
          setActiveParent={setActiveParent}
          activeChild={activeChild}
          setActiveChild={setActiveChild}
        /&gt;
      ))}
    &lt;/&gt;
  );
}
</code></pre>
<p><code>activeParent</code> tracks which top-level nav item is currently selected. It initializes to the title of the first non-section item, so the sidebar always has a selection on first render, and you never show the sidebar with nothing highlighted. <code>activeChild</code> tracks which sub-item inside a collapsible menu is selected.</p>
<p>Both state values are passed down as props to each <code>NavMainItem</code>, so every item in the list can read the current selection and trigger updates to it.</p>
<h2 id="heading-how-to-handle-active-states-and-collapsible-menus"><strong>How to Handle Active States and Collapsible Menus</strong></h2>
<p>The <code>NavMainItem</code> function branches into one of three rendering paths based on the shape of the incoming item.</p>
<h3 id="heading-how-to-render-section-labels">How to Render Section Labels</h3>
<pre><code class="language-javascript">if (item.isSection &amp;&amp; item.label) {
  return (
    &lt;SidebarGroup className="p-0 pt-5 first:pt-0"&gt;
      &lt;SidebarGroupLabel className="p-0 text-xs font-medium uppercase text-sidebar-foreground"&gt;
        {item.label}
      &lt;/SidebarGroupLabel&gt;
    &lt;/SidebarGroup&gt;
  );
}
</code></pre>
<p>Section labels use <code>first:pt-0</code> to remove the top padding from the very first section, so the nav starts flush with the header.</p>
<h3 id="heading-how-to-render-collapsible-parent-items">How to Render Collapsible Parent Items</h3>
<pre><code class="language-javascript">if (hasChildren &amp;&amp; item.title) {
  return (
    &lt;SidebarGroup className="p-0"&gt;
      &lt;SidebarMenu&gt;
        &lt;Collapsible open={isOpen} onOpenChange={setIsOpen}&gt;
          &lt;SidebarMenuItem&gt;
            &lt;CollapsibleTrigger
              className="w-full"
              render={
                &lt;SidebarMenuButton
                  id={`nav-main-trigger-${item.title.toLowerCase().replace(/\s+/g, '-')}`}
                  tooltip={item.title}
                  isActive={isParentActive}
                  onClick={() =&gt; setActiveParent(item.title!)}
                  className={cn(
                    "rounded-md text-sm font-medium px-3 py-2 h-9 transition-colors cursor-pointer",
                    isParentActive ? "bg-primary! text-primary-foreground!" : ""
                  )}
                &gt;
                  {item.icon &amp;&amp; &lt;item.icon size={16} /&gt;}
                  &lt;span&gt;{item.title}&lt;/span&gt;
                  &lt;ChevronRight
                    className={cn(
                      "ml-auto transition-transform duration-200",
                      isOpen &amp;&amp; "rotate-90"
                    )}
                  /&gt;
                &lt;/SidebarMenuButton&gt;
              }
            /&gt;
            &lt;CollapsibleContent&gt;
              &lt;SidebarMenuSub className="me-0 pe-0"&gt;
                {item.children!.map((child, index) =&gt; (
                  &lt;NavMainSubItem
                    key={child.title || index}
                    item={child}
                    activeParent={activeParent}
                    setActiveParent={setActiveParent}
                    activeChild={activeChild}
                    setActiveChild={setActiveChild}
                    parentTitle={item.title}
                  /&gt;
                ))}
              &lt;/SidebarMenuSub&gt;
            &lt;/CollapsibleContent&gt;
          &lt;/SidebarMenuItem&gt;
        &lt;/Collapsible&gt;
      &lt;/SidebarMenu&gt;
    &lt;/SidebarGroup&gt;
  );
}
</code></pre>
<p>A <code>useEffect</code> inside <code>NavMainItem</code> syncs the local <code>isOpen</code> state with <code>activeParent</code> so that when a different parent is activated, the previously open collapsible stays open until the user explicitly closes it:</p>
<pre><code class="language-javascript">React.useEffect(() =&gt; {
  if (isParentActive) {
    setIsOpen(true);
  }
}, [isParentActive]);
</code></pre>
<p>The <code>ChevronRight</code> icon rotates 90 degrees when the submenu is open, using a Tailwind transition class:</p>
<pre><code class="language-javascript">&lt;ChevronRight
  className={cn(
    "ml-auto transition-transform duration-200",
    isOpen &amp;&amp; "rotate-90"
  )}
/&gt;
</code></pre>
<h3 id="heading-how-to-render-leaf-items">How to Render Leaf Items</h3>
<pre><code class="language-javascript">if (item.title) {
  return (
    &lt;SidebarGroup className="p-0"&gt;
      &lt;SidebarMenu&gt;
        &lt;SidebarMenuItem&gt;
          &lt;SidebarMenuButton
            id={`nav-main-button-${item.title.toLowerCase().replace(/\s+/g, '-')}`}
            tooltip={item.title}
            isActive={isParentActive}
            onClick={() =&gt; {
              setActiveParent(item.title!);
              setActiveChild(null);
            }}
            className={cn(
              "rounded-md text-sm font-medium px-3 py-2 h-9 transition-colors cursor-pointer",
              isParentActive ? "bg-primary! text-primary-foreground!" : ""
            )}
            render={&lt;a href={item.href} /&gt;}
          &gt;
            {item.icon &amp;&amp; &lt;item.icon /&gt;}
            {item.title}
          &lt;/SidebarMenuButton&gt;
        &lt;/SidebarMenuItem&gt;
      &lt;/SidebarMenu&gt;
    &lt;/SidebarGroup&gt;
  );
}
</code></pre>
<p>The <code>render</code> prop on <code>SidebarMenuButton</code> replaces the default button element with an <code>&lt;a&gt;</code> tag. This preserves correct anchor link semantics and accessibility while keeping the button's visual styling. When a leaf item is clicked, <code>activeChild</code> is reset to <code>null</code> since there is no child to track.</p>
<h3 id="heading-how-to-render-child-items-in-a-submenu">How to Render Child Items in a Submenu</h3>
<p>The <code>NavMainSubItem</code> function handles sub-items inside a collapsible. When a child is clicked, it sets both <code>activeChild</code> to itself and <code>activeParent</code> to its parent's title so the parent item remains visually highlighted:</p>
<pre><code class="language-javascript">if (item.title) {
  return (
    &lt;SidebarMenuSubItem className="w-full"&gt;
      &lt;SidebarMenuSubButton
        id={`nav-sub-button-${item.title.toLowerCase().replace(/\s+/g, '-')}`}
        className={cn(
          "w-full rounded-md transition-colors",
          activeChild === item.title ? "bg-muted! text-foreground!" : ""
        )}
        isActive={activeChild === item.title}
        onClick={() =&gt; {
          setActiveParent(parentTitle || "");
          setActiveChild(item.title!);
        }}
        render={&lt;a href={item.href}&gt;{item.title}&lt;/a&gt;}
      /&gt;
    &lt;/SidebarMenuSubItem&gt;
  );
}
</code></pre>
<p>The child uses a different active style (<code>bg-muted</code> with <code>text-foreground</code>) compared to the parent (<code>bg-primary</code> with <code>text-primary-foreground</code>). This visual distinction makes it easy to see both which section you are in and which specific page is currently active.</p>
<p>Sub-items also support nesting. If a child item itself has a <code>children</code> array, <code>NavMainSubItem</code> renders another <code>Collapsible</code> with a nested <code>SidebarMenuSub</code>, allowing you to build multi-level navigation trees without any changes to the data structure.</p>
<h2 id="heading-how-to-style-the-sidebar"><strong>How to Style the Sidebar</strong></h2>
<p>The full <code>AppSidebar</code> render function puts all of the pieces together:</p>
<pre><code class="language-javascript">export function AppSidebar() {
  return (
    &lt;Sidebar variant="floating" className="p-4 h-full [&amp;_[data-slot=sidebar-inner]]:h-full"&gt;
      &lt;div className="flex flex-col gap-6 overflow-hidden"&gt;

        {/* Header with Logo */}
        &lt;SidebarHeader className="px-4"&gt;
          &lt;SidebarMenu&gt;
            &lt;SidebarMenuItem&gt;
              &lt;a href="#" className="w-full h-full"&gt;
                &lt;Logo /&gt;
              &lt;/a&gt;
            &lt;/SidebarMenuItem&gt;
          &lt;/SidebarMenu&gt;
        &lt;/SidebarHeader&gt;

        {/* Scrollable Navigation Content */}
        &lt;SidebarContent className="overflow-hidden"&gt;
          &lt;ScrollArea className="h-[calc(100vh-100px)]"&gt;
            &lt;div className="px-4"&gt;
              &lt;NavMain items={navData} /&gt;
            &lt;/div&gt;

            {/* Promotional Card */}
            &lt;div className="pt-5 px-4"&gt;
              &lt;Card className="shadow-none ring-0 bg-secondary px-4 py-6"&gt;
                &lt;CardContent className="p-0 flex flex-col gap-3 items-center"&gt;
                  &lt;img
                    src="https://images.shadcnspace.com/assets/backgrounds/download-img.png"
                    alt="sidebar-img"
                    width={74}
                    height={74}
                    className="h-20 w-20"
                  /&gt;
                  &lt;div className="flex flex-col gap-4 items-center"&gt;
                    &lt;div&gt;
                      &lt;p className="text-base font-semibold text-card-foreground text-center"&gt;
                        Grab Pro Now
                      &lt;/p&gt;
                      &lt;p className="text-sm font-regular text-muted-foreground text-center"&gt;
                        Customize your admin
                      &lt;/p&gt;
                    &lt;/div&gt;
                    &lt;Button className="w-fit h-9 px-4 py-2 shadow-none cursor-pointer rounded-xl hover:bg-primary/80"&gt;
                      Get Premium
                    &lt;/Button&gt;
                  &lt;/div&gt;
                &lt;/CardContent&gt;
              &lt;/Card&gt;
            &lt;/div&gt;
          &lt;/ScrollArea&gt;
        &lt;/SidebarContent&gt;

      &lt;/div&gt;
    &lt;/Sidebar&gt;
  );
}
</code></pre>
<p>Let's walk through the key styling decisions:</p>
<p><code>variant="floating"</code> gives the sidebar a card-like appearance with rounded corners and a subtle drop shadow. It visually lifts the sidebar off the background rather than making it flush with the page edge like a standard sidebar would.</p>
<p><code>[&amp;_[data-slot=sidebar-inner]]:h-full</code> is an arbitrary Tailwind variant selector that targets shadcn/ui's internal sidebar slot element. Without this, the sidebar inner container doesn't fill the full available height, which breaks the layout. The <code>data-slot</code> attribute is how shadcn/ui identifies internal sub-elements of compound components.</p>
<p><code>h-[calc(100vh-100px)]</code> on <code>ScrollArea</code> makes the navigation list independently scrollable. The 100px offset accounts for the sidebar header and padding, so the scroll area doesn't overflow the viewport. The rest of the page layout remains static while the nav scrolls.</p>
<p>The <code>bg-secondary</code> card at the bottom of the scroll area is a common admin dashboard pattern, a soft prompt for an upgrade or onboarding action that lives passively in the sidebar without blocking navigation.</p>
<p>For more details on the Sidebar component's API, variants, and configuration options, refer to the official shadcn/ui sidebar docs.</p>
<h2 id="heading-live-preview"><strong>Live Preview</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/68b53a3d851476bd2ce87f12/f1538441-fa73-4eb0-af91-04f5bf4fab08.png" alt="f1538441-fa73-4eb0-af91-04f5bf4fab08" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<h2 id="heading-summary"><strong>Summary</strong></h2>
<p>Congratulations! You have now built a complete, production-ready admin dashboard sidebar using shadcn/ui and a community block from Shadcn Space.</p>
<p>Here is a recap of everything you covered:</p>
<ul>
<li><p>Setting up a Next.js project with shadcn/ui initialized and a pre-built sidebar block installed from Shadcn Space</p>
</li>
<li><p>Using <code>SidebarProvider</code> and <code>SidebarTrigger</code> to manage the sidebar open/closed state across a page layout through React context</p>
</li>
<li><p>Defining navigation data as a flat array of typed <code>NavItem</code> objects covering section labels, leaf items, and collapsible parent items</p>
</li>
<li><p>Rendering all three item types from a single <code>navData</code> source in the <code>NavMain</code> and <code>NavMainItem</code> components</p>
</li>
<li><p>Tracking <code>activeParent</code> and <code>activeChild</code> state in a single location and passing them as props so every item can read and update the shared selection state</p>
</li>
<li><p>Using <code>Collapsible</code> with a <code>useEffect</code> sync to keep parent items open when they are active, and animate the chevron icon on expand and collapse</p>
</li>
<li><p>Applying the <code>floating</code> variant, an arbitrary Tailwind slot selector, and <code>ScrollArea</code> with a calculated height to produce a polished, production-appropriate sidebar layout</p>
</li>
</ul>
<p>This pattern scales well beyond what you built here. You can extend <code>NavItem</code> with additional fields like badge counts, permission flags, or external link indicators. You can swap in real <code>href</code> values and connect <code>activeParent</code> and <code>activeChild</code> to your router so the selection always reflects the current URL. You can also add more sections to <code>navData</code> without touching any rendering logic.</p>
<p>For a quick checkout, we have used the Shadcn Space free Shadcn dashboard block in this <a href="https://shadcnspace.com/blocks/dashboard-ui/dashboard-shell"><strong>dashboard shell</strong></a>.</p>
<p>If you want to explore more pre-built admin UI blocks, components, and templates built on top of shadcn/ui, you can browse the full library at <a href="https://shadcnspace.com/"><strong>Shadcn Space</strong></a>.</p>
<h3 id="heading-resources"><strong>Resources</strong></h3>
<ul>
<li><p><a href="https://shadcnspace.com/blocks"><strong>Shadcn UI Blocks</strong></a></p>
</li>
<li><p><a href="https://shadcnspace.com/components"><strong>Shadcn UI Components</strong></a></p>
</li>
<li><p><a href="https://shadcnspace.com/docs/getting-started/blocks"><strong>Shadcn Space Getting Started Docs</strong></a></p>
</li>
<li><p><a href="https://www.figma.com/community/file/1597967874273587400/shadcn-space-figma-ui-kit"><strong>Figma UI Kit Design System</strong></a></p>
</li>
<li><p><a href="https://ui.shadcn.com/docs/components/sidebar"><strong>shadcn/ui Sidebar Docs</strong></a></p>
</li>
<li><p><a href="https://base-ui.com/"><strong>Base UI</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Responsive and Accessible UI Designs with React and Semantic HTML ]]>
                </title>
                <description>
                    <![CDATA[ Building modern React applications requires more than just functionality. It also demands responsive layouts and accessible user experiences. By combining semantic HTML, responsive design techniques,  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-responsive-accessible-ui-with-react-and-semantic-html/</link>
                <guid isPermaLink="false">69d539975da14bc70e76871d</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Accessibility ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Responsive Web Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ semantichtml ]]>
                    </category>
                
                    <category>
                        <![CDATA[ aria ]]>
                    </category>
                
                    <category>
                        <![CDATA[ UI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Gopinath Karunanithi ]]>
                </dc:creator>
                <pubDate>Tue, 07 Apr 2026 17:06:31 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d2651d02-040d-4c4f-bbfe-ef92097edab4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Building modern React applications requires more than just functionality. It also demands responsive layouts and accessible user experiences.</p>
<p>By combining semantic HTML, responsive design techniques, and accessibility best practices (like ARIA roles and keyboard navigation), developers can create interfaces that work across devices and for all users, including those with disabilities.</p>
<p>This article shows how to design scalable, inclusive React UIs using real-world patterns and code examples.</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-overview">Overview</a></p>
</li>
<li><p><a href="#heading-why-accessibility-and-responsiveness-matter">Why Accessibility and Responsiveness Matter</a></p>
</li>
<li><p><a href="#heading-core-principles-of-accessible-and-responsive-design">Core Principles of Accessible and Responsive Design</a></p>
</li>
<li><p><a href="#heading-using-semantic-html-in-react">Using Semantic HTML in React</a></p>
</li>
<li><p><a href="#heading-structuring-a-page-with-semantic-elements">Structuring a Page with Semantic Elements</a></p>
</li>
<li><p><a href="#heading-building-responsive-layouts">Building Responsive Layouts</a></p>
</li>
<li><p><a href="#heading-accessibility-with-aria">Accessibility with ARIA</a></p>
</li>
<li><p><a href="#heading-keyboard-navigation">Keyboard Navigation</a></p>
</li>
<li><p><a href="#heading-focus-management">Focus Management</a></p>
</li>
<li><p><a href="#heading-forms-and-accessibility">Forms and Accessibility</a></p>
</li>
<li><p><a href="#heading-responsive-typography-and-images">Responsive Typography and Images</a></p>
</li>
<li><p><a href="#heading-building-a-fully-accessible-responsive-component-end-to-end-example">Building a Fully Accessible Responsive Component (End-to-End Example)</a></p>
</li>
<li><p><a href="#heading-testing-accessibility">Testing Accessibility</a></p>
</li>
<li><p><a href="#heading-best-practices">Best Practices</a></p>
</li>
<li><p><a href="#heading-when-not-to-overuse-accessibility-features">When NOT to Overuse Accessibility Features</a></p>
</li>
<li><p><a href="#heading-future-enhancements">Future Enhancements</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you should be familiar with:</p>
<ul>
<li><p>React fundamentals (components, hooks, JSX)</p>
</li>
<li><p>Basic HTML and CSS</p>
</li>
<li><p>JavaScript ES6 features</p>
</li>
<li><p>Basic understanding of accessibility concepts (helpful but not required)</p>
</li>
</ul>
<h2 id="heading-overview">Overview</h2>
<p>Modern web applications must serve a diverse audience across a wide range of devices, screen sizes, and accessibility needs. Users today expect seamless experiences whether they are browsing on a desktop, tablet, or mobile device – and they also expect interfaces that are usable regardless of physical or cognitive limitations.</p>
<p>Two essential principles help achieve this:</p>
<ul>
<li><p>Responsive design, which ensures layouts adapt to different screen sizes</p>
</li>
<li><p>Accessibility, which ensures applications are usable by people with disabilities</p>
</li>
</ul>
<p>In React applications, these principles are often implemented incorrectly or treated as afterthoughts. Developers may rely heavily on div-based layouts, ignore semantic HTML, or overlook accessibility features such as keyboard navigation and screen reader support.</p>
<p>This article will show you how to build responsive and accessible UI designs in React using semantic HTML. You'll learn how to:</p>
<ul>
<li><p>Structure components using semantic HTML elements</p>
</li>
<li><p>Build responsive layouts using modern CSS techniques</p>
</li>
<li><p>Improve accessibility with ARIA attributes and proper roles</p>
</li>
<li><p>Ensure keyboard navigation and screen reader compatibility</p>
</li>
<li><p>Apply best practices for scalable and inclusive UI design</p>
</li>
</ul>
<p>By the end of this guide, you'll be able to create React interfaces that are not only visually responsive but also accessible to all users.</p>
<h2 id="heading-why-accessibility-and-responsiveness-matter">Why Accessibility and Responsiveness Matter</h2>
<p>Responsive and accessible design isn't just about compliance. It directly impacts usability, performance, and reach.</p>
<p><strong>Accessibility benefits:</strong></p>
<ul>
<li><p>Supports users with visual, motor, or cognitive impairments</p>
</li>
<li><p>Improves SEO and content discoverability</p>
</li>
<li><p>Enhances usability for all users</p>
</li>
</ul>
<p><strong>Responsiveness benefits:</strong></p>
<ul>
<li><p>Ensures consistent UX across devices</p>
</li>
<li><p>Reduces bounce rates on mobile</p>
</li>
<li><p>Improves performance and scalability</p>
</li>
</ul>
<p>Ignoring these principles can result in broken layouts on smaller screens, poor screen reader compatibility, and limited reach and usability.</p>
<h2 id="heading-core-principles-of-accessible-and-responsive-design">Core Principles of Accessible and Responsive Design</h2>
<p>Before diving into the code, it’s important to understand the foundational principles.</p>
<h3 id="heading-1-semantic-html-first">1. Semantic HTML First</h3>
<p>Semantic HTML refers to using HTML elements that clearly describe their meaning and role in the interface, rather than relying on generic containers like <code>&lt;div&gt; or &lt;span&gt;.</code>These elements provide built-in accessibility, improve SEO, and make code more readable.</p>
<p>For example:</p>
<p><strong>Non-semantic:</strong></p>
<pre><code class="language-html">&lt;div onClick={handleClick}&gt;Submit&lt;/div&gt;
</code></pre>
<p><strong>Semantic:</strong></p>
<pre><code class="language-html">&lt;button type="button" onClick={handleClick}&gt;Submit&lt;/button&gt;
</code></pre>
<p>Another example:</p>
<p><strong>Non-semantic:</strong></p>
<pre><code class="language-html">&lt;div className="header"&gt;My App&lt;/div&gt;
</code></pre>
<p><strong>Semantic:</strong></p>
<pre><code class="language-html">&lt;header&gt;My App&lt;/header&gt;
</code></pre>
<p>Using semantic elements such as <code>&lt;header&gt;</code>, <code>&lt;nav&gt;</code>, <code>&lt;main&gt;</code>, <code>&lt;section&gt;</code>, <code>&lt;article&gt;</code>, and <code>&lt;button&gt;</code> helps browsers and assistive technologies (like screen readers) understand the structure and purpose of your UI without additional configuration.</p>
<p>Why this matters:</p>
<ul>
<li><p>Screen readers understand semantic elements automatically</p>
</li>
<li><p>It supports built-in accessibility (keyboard, focus, roles)</p>
</li>
<li><p>There's less need for ARIA attributes</p>
</li>
<li><p>It gives you better SEO and maintainability</p>
</li>
</ul>
<h3 id="heading-2-mobile-first-design">2. Mobile-First Design</h3>
<p>Mobile-first design means starting your UI design with the smallest screen sizes (typically mobile devices) and progressively enhancing the layout for larger screens such as tablets and desktops.</p>
<p>This approach makes sure that core content and functionality are prioritized, layouts remain simple and performant, and users on mobile devices get a fully usable experience.</p>
<p>In practice, mobile-first design involves:</p>
<ul>
<li><p>Using a single-column layout initially</p>
</li>
<li><p>Applying minimal styling and spacing</p>
</li>
<li><p>Avoiding complex UI patterns on small screens</p>
</li>
</ul>
<p>Then, you scale up using CSS media queries:</p>
<pre><code class="language-css">.container {
  display: flex;
  flex-direction: column;
}
@media (min-width: 768px) {
  .container {
    flex-direction: row;
  }
}
</code></pre>
<p>Here, the default layout is optimized for mobile, and enhancements are applied only when the screen size increases.</p>
<p><strong>Why this approach works:</strong></p>
<ul>
<li><p>Prioritizes essential content</p>
</li>
<li><p>Improves performance on mobile devices</p>
</li>
<li><p>Reduces layout bugs when scaling up</p>
</li>
<li><p>Aligns with how most users access web apps today</p>
</li>
</ul>
<h3 id="heading-3-progressive-enhancement">3. Progressive Enhancement</h3>
<p>Progressive enhancement is the practice of building a baseline user experience that works for all users (regardless of their device, browser capabilities, or network conditions) and then layering on advanced features for more capable environments.</p>
<p>This approach ensures that core functionality is always accessible, users on older devices or slow networks aren't blocked, and accessibility is preserved even when advanced features fail.</p>
<p>In practice, this means:</p>
<ul>
<li><p>Start with semantic HTML that delivers content and functionality</p>
</li>
<li><p>Add basic styling with CSS for layout and readability</p>
</li>
<li><p>Enhance interactivity using JavaScript (React) only where needed</p>
</li>
</ul>
<p>For example, a form should still be usable with plain HTML:</p>
<pre><code class="language-html">&lt;form&gt;
  &lt;label htmlFor="email"&gt;Email&lt;/label&gt;
  &lt;input id="email" type="email" /&gt;
  &lt;button type="submit"&gt;Submit&lt;/button&gt;
&lt;/form&gt;
</code></pre>
<p>Then, React can enhance it with validation, dynamic feedback, or animations.</p>
<p>By prioritizing functionality first and enhancements later, you ensure your application remains usable in a wide range of real-world scenarios.</p>
<h3 id="heading-4-keyboard-accessibility">4. Keyboard Accessibility</h3>
<p>Keyboard accessibility ensures that users can navigate and interact with your application using only a keyboard. This is critical for users with motor disabilities and also improves usability for power users.</p>
<p>Key aspects of keyboard accessibility include:</p>
<ul>
<li><p>Ensuring all interactive elements (buttons, links, inputs) are focusable</p>
</li>
<li><p>Maintaining a logical tab order across the page</p>
</li>
<li><p>Providing visible focus indicators (for example, outline styles)</p>
</li>
<li><p>Supporting keyboard events such as Enter and Space</p>
</li>
</ul>
<p><strong>Bad Example (Not Accessible)</strong></p>
<pre><code class="language-html">&lt;div onClick={handleClick}&gt;Submit&lt;/div&gt;
</code></pre>
<p>This element:</p>
<ul>
<li><p>Cannot be focused with a keyboard</p>
</li>
<li><p>Does not respond to Enter/Space</p>
</li>
<li><p>Is invisible to screen readers</p>
</li>
</ul>
<p><strong>Good Example</strong></p>
<pre><code class="language-html">&lt;button type="button" onClick={handleClick}&gt;Submit&lt;/button&gt;
</code></pre>
<p>This automatically supports:</p>
<ul>
<li><p>Keyboard interaction</p>
</li>
<li><p>Focus management</p>
</li>
<li><p>Screen reader announcements</p>
</li>
</ul>
<p><strong>Custom Component Example (if needed)</strong></p>
<pre><code class="language-html">&lt;div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) =&gt; {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleClick();
    }
  }}
&gt;
  Submit
&lt;/div&gt;
</code></pre>
<p>But only use this when native elements aren't sufficient.</p>
<p>These principles form the foundation of accessible and responsive design:</p>
<ul>
<li><p>Use semantic HTML to communicate intent</p>
</li>
<li><p>Design for mobile first, then scale up</p>
</li>
<li><p>Enhance progressively for better compatibility</p>
</li>
<li><p>Ensure full keyboard accessibility</p>
</li>
</ul>
<p>Applying these early prevents major usability and accessibility issues later in development.</p>
<h2 id="heading-using-semantic-html-in-react">Using Semantic HTML in React</h2>
<p>As we briefly discussed above, semantic HTML plays a critical role in both accessibility (a11y) and code readability. Semantic elements clearly describe their purpose to both developers and browsers, which allows assistive technologies like screen readers to interpret and navigate the UI correctly.</p>
<p>For example, when you use a <code>&lt;button&gt;</code> element, browsers automatically provide keyboard support, focus behavior, and accessibility roles. In contrast, non-semantic elements like <code>&lt;div&gt;</code>require additional attributes and manual handling to achieve the same functionality.</p>
<p>From a readability perspective, semantic HTML makes your code easier to understand and maintain. Developers can quickly identify the structure and intent of a component without relying on class names or external documentation.</p>
<p><strong>Bad Example (Non-semantic)</strong></p>
<pre><code class="language-html">&lt;div onClick={handleClick}&gt;Submit&lt;/div&gt;
</code></pre>
<p>Why this is problematic:</p>
<ul>
<li><p>The <code>&lt;div&gt;</code>element has no inherent meaning or role</p>
</li>
<li><p>It is not focusable by default, so keyboard users can't access it</p>
</li>
<li><p>It does not respond to keyboard events like Enter or Space unless explicitly coded</p>
</li>
<li><p>Screen readers do not recognize it as an interactive element</p>
</li>
</ul>
<p>To make this accessible, you would need to add:</p>
<p><code>role="button"</code></p>
<p><code>tabIndex="0"</code></p>
<p><code>Keyboard event handlers</code></p>
<p><strong>Good Example (Semantic)</strong></p>
<pre><code class="language-html">&lt;button type="button" onClick={handleClick}&gt;Submit&lt;/button&gt;
</code></pre>
<p>Why this is better:</p>
<ul>
<li><p>The <code>&lt;button&gt;</code> element is inherently interactive</p>
</li>
<li><p>It is automatically focusable and keyboard accessible</p>
</li>
<li><p>It supports Enter and Space key activation by default</p>
</li>
<li><p>Screen readers correctly announce it as a button</p>
</li>
</ul>
<p>This reduces complexity while improving accessibility and usability.</p>
<h3 id="heading-why-all-this-matters">Why all this matters:</h3>
<p>There are many reasons to use semantic HTML.</p>
<p>First, semantic elements like <code>&lt;button&gt;, &lt;a&gt;,</code> and <code>&lt;form&gt;</code> come with default accessibility behaviors such as focus management and keyboard interaction</p>
<p>It also reduces complexity: you don’t need to manually implement roles, keyboard handlers, or tab navigation</p>
<p>They provide better screen reader support as well. Assistive technologies can correctly interpret the purpose of elements and announce them appropriately</p>
<p>Semantic HTML also improves maintainability and helps other developers quickly understand the intent of your code without reverse-engineering behavior from event handlers</p>
<p>Finally, you'll generally have fewer bugs in your code. Relying on native browser behavior reduces the risk of missing critical accessibility features</p>
<p>Here's another example:</p>
<p><strong>Non-semantic:</strong></p>
<pre><code class="language-html">&lt;div className="nav"&gt;
  &lt;div onClick={goHome}&gt;Home&lt;/div&gt;
&lt;/div&gt;
</code></pre>
<p><strong>Semantic:</strong></p>
<pre><code class="language-html">&lt;nav&gt;
  &lt;a href="/"&gt;Home&lt;/a&gt;
&lt;/nav&gt;
</code></pre>
<p>Here, <code>&lt;nav&gt;</code> clearly defines a navigation region, and <code>&lt;a&gt;</code> provides built-in link behavior, including keyboard navigation and proper screen reader announcements.</p>
<h2 id="heading-structuring-a-page-with-semantic-elements">Structuring a Page with Semantic Elements</h2>
<p>When building a React application, structuring your layout with semantic HTML elements helps define clear regions of your interface. Instead of relying on generic containers like <code>&lt;div&gt;</code>, semantic elements communicate the purpose of each section to both developers and assistive technologies.</p>
<p>In the example below, we're creating a basic page layout using commonly used semantic elements such as <code>&lt;header&gt;</code>, <code>&lt;nav&gt;</code>, <code>&lt;main&gt;</code>, <code>&lt;section&gt;</code>, and <code>&lt;footer&gt;</code>. Each of these elements represents a specific part of the UI and contributes to better accessibility and maintainability.</p>
<pre><code class="language-javascript">function Layout() {
  return (
    &lt;&gt;
      {/* Skip link for keyboard and screen reader users */}
      &lt;a href="#main-content" className="skip-link"&gt;
        Skip to main content
      &lt;/a&gt;

      &lt;header&gt;
        &lt;h1&gt;My App&lt;/h1&gt;
      &lt;/header&gt;

      &lt;nav&gt;
        &lt;ul&gt;
          &lt;li&gt;&lt;a href="/"&gt;Home&lt;/a&gt;&lt;/li&gt;
        &lt;/ul&gt;
      &lt;/nav&gt;

      &lt;main id="main-content"&gt;
        &lt;section&gt;
          &lt;h2&gt;Dashboard&lt;/h2&gt;
        &lt;/section&gt;
      &lt;/main&gt;

      &lt;footer&gt;
        &lt;p&gt;© 2026&lt;/p&gt;
      &lt;/footer&gt;
    &lt;/&gt;
  );
}
</code></pre>
<p>Each element in this layout has a specific role:</p>
<ul>
<li><p>The skip link allows screen reader users to skip to the main content</p>
</li>
<li><p><code>&lt;header&gt;</code>: Represents introductory content or branding</p>
</li>
<li><p><code>&lt;nav&gt;</code>: Contains navigation links</p>
</li>
<li><p><code>&lt;main&gt;</code>: Holds the primary content of the page</p>
</li>
<li><p><code>&lt;section&gt;</code>: Groups related content within the page</p>
</li>
<li><p><code>&lt;footer&gt;</code>: Contains closing or supplementary information</p>
</li>
</ul>
<p>Using these elements correctly ensures your UI is both logically structured and accessible by default.</p>
<h3 id="heading-why-this-structure-is-important">Why this structure is important:</h3>
<p>Properly structuring a page like this brings with it many benefits.</p>
<p>For example, it gives you Improved screen reader navigation. This is because semantic elements allow screen readers to identify different regions of the page (for example, navigation, main content, footer). Users can quickly jump between these sections instead of reading the page linearly</p>
<p>It also gives you better document structure. Elements like <code>&lt;main&gt;</code> and <code>&lt;section&gt;</code> define a logical hierarchy, making content easier to parse for both browsers and assistive technologies</p>
<p>Search engines also use semantic structure to better understand page content and prioritize important sections, resulting in better SEO.</p>
<p>It also makes your code more readable, so other devs can immediately understand the layout and purpose of each section without relying on class names or comments</p>
<p>And it provides built-in accessibility landmarks using elements like <code>&lt;nav&gt;</code> and <code>&lt;main&gt;</code>, allowing assistive technologies to provide shortcuts for users.</p>
<h2 id="heading-building-responsive-layouts">Building Responsive Layouts</h2>
<p>Responsive layouts ensure that your UI adapts smoothly across different screen sizes, from mobile devices to large desktop displays. Instead of building separate layouts for each device, modern CSS techniques like Flexbox, Grid, and media queries allow you to create flexible, fluid designs.</p>
<p>In this section, we’ll look at how layout behavior changes based on screen size, starting with a mobile-first approach and progressively enhancing the layout for larger screens.</p>
<p><strong>Using CSS Flexbox:</strong></p>
<pre><code class="language-css">.container {
  display: flex;
  flex-direction: column;
}

@media (min-width: 768px) {
  .container {
    flex-direction: row;
  }
}
</code></pre>
<p>On smaller screens (mobile), elements are stacked vertically using <code>flex-direction: column</code>, making content easier to read and scroll.</p>
<p>On larger screens (768px and above), the layout switches to a horizontal row, utilizing available screen space more efficiently.</p>
<p><strong>Why this helps:</strong></p>
<ul>
<li><p>Ensures content is readable on small devices without horizontal scrolling</p>
</li>
<li><p>Improves layout efficiency on larger screens</p>
</li>
<li><p>Supports a mobile-first design strategy by defining the default layout for smaller screens first and enhancing it progressively</p>
</li>
</ul>
<p><strong>Using CSS Grid:</strong></p>
<pre><code class="language-css">.grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 16px;
}

@media (min-width: 768px) {
  .grid {
    grid-template-columns: repeat(3, 1fr);
  }
}
</code></pre>
<p>On mobile devices, content is displayed in a single-column layout (<code>1fr</code>), ensuring each item takes full width.</p>
<p>On larger screens, the layout shifts to three equal columns using <code>repeat(3, 1fr)</code>, creating a grid structure.</p>
<p><strong>Why this helps:</strong></p>
<ul>
<li><p>Provides a clean and consistent way to manage complex layouts</p>
</li>
<li><p>Makes it easy to scale from simple to multi-column designs</p>
</li>
<li><p>Improves visual balance and spacing across different screen sizes</p>
</li>
</ul>
<p><strong>React Example:</strong></p>
<pre><code class="language-javascript">function CardGrid() {
  return (
    &lt;div className="grid"&gt;
      &lt;div className="card"&gt;Item 1&lt;/div&gt;
      &lt;div className="card"&gt;Item 2&lt;/div&gt;
      &lt;div className="card"&gt;Item 3&lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The React component uses the .grid class to apply responsive Grid behavior. Each card automatically adjusts its position based on screen size.</p>
<p><strong>Why this is effective:</strong></p>
<ul>
<li><p>Separates structure (React JSX) from layout (CSS)</p>
</li>
<li><p>Allows you to reuse the same component across different screen sizes without modification</p>
</li>
<li><p>Ensures consistent responsiveness across your application with minimal code</p>
</li>
</ul>
<p>By combining Flexbox for one-dimensional layouts and Grid for two-dimensional layouts, you can build highly adaptable interfaces that respond efficiently to different devices and screen sizes.</p>
<h2 id="heading-accessibility-with-aria">Accessibility with ARIA</h2>
<p>ARIA (Accessible Rich Internet Applications) is a set of attributes that enhance the accessibility of web content, especially when building custom UI components that cannot be fully implemented using native HTML elements.</p>
<p>ARIA works by providing additional semantic information to assistive technologies such as screen readers. It does this through:</p>
<ul>
<li><p>Roles, which define what an element is (for example, button, dialog, menu)</p>
</li>
<li><p>States and properties, which describe the current condition or behavior of an element (for example, expanded, hidden, live updates)</p>
</li>
</ul>
<p>For example, when you create a custom dropdown using <code>&lt;div&gt;</code> elements, browsers don't inherently understand its purpose. By applying ARIA roles and attributes, you can communicate that this structure behaves like a menu and ensure it is interpreted correctly.</p>
<p>Just make sure you use ARIA carefully. Incorrect or unnecessary usage can reduce accessibility. Here's a key rule to follow: use native HTML first. Only use ARIA when necessary.</p>
<p>ARIA is especially useful for:</p>
<ul>
<li><p>Custom UI components (modals, tabs, dropdowns)</p>
</li>
<li><p>Dynamic content updates</p>
</li>
<li><p>Complex interactions not covered by standard HTML</p>
</li>
</ul>
<p>Something to note before we get into the examples here: real-world accessibility is complex. For production apps, you should typically prefer well-tested libraries like react-aria, Radix UI, or Headless UI. These examples are primarily for educational purposes and aren't production-ready.</p>
<p><strong>Example: Accessible Modal</strong></p>
<pre><code class="language-javascript">function Modal({ isOpen, onClose }) {
  const dialogRef = React.useRef();

  React.useEffect(() =&gt; {
    if (isOpen) {
      dialogRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    &lt;div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      tabIndex={-1}
      ref={dialogRef}
      onKeyDown={(e) =&gt; {
        if (e.key === 'Escape') onClose();
      }}
    &gt;
      &lt;h2 id="modal-title"&gt;Modal Title&lt;/h2&gt;
      &lt;button type="button" onClick={onClose}&gt;Close&lt;/button&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p><strong>How this works:</strong></p>
<ul>
<li><p><code>role="dialog"</code> identifies the element as a modal dialog</p>
</li>
<li><p><code>aria-modal="true"</code> indicates that background content is inactive</p>
</li>
<li><p><code>aria-labelledby</code> connects the dialog to its visible title for screen readers</p>
</li>
<li><p><code>tabIndex={-1}</code> allows the dialog container to receive focus programmatically</p>
</li>
<li><p>Focus is moved to the dialog when it opens</p>
</li>
<li><p>Pressing Escape closes the modal, which is a standard accessibility expectation</p>
</li>
</ul>
<p>This ensures that users can understand, navigate, and exit the modal using both keyboard and assistive technologies.</p>
<h3 id="heading-key-aria-attributes">Key ARIA Attributes</h3>
<h4 id="heading-1-role">1. role</h4>
<p>Defines the type of element and its purpose. For example, <code>role="dialog"</code> tells assistive technologies that the element behaves like a modal dialog.</p>
<h4 id="heading-2-aria-label">2. aria-label</h4>
<p>Provides an accessible name for an element when visible text is not sufficient. Screen readers use this label to describe the element to users.</p>
<h4 id="heading-3-aria-hidden">3. aria-hidden</h4>
<p>Indicates whether an element should be ignored by assistive technologies. For example, <code>aria-hidden="true"</code> hides decorative elements from screen readers.</p>
<h4 id="heading-4-aria-live">4. aria-live</h4>
<p>Used for dynamic content updates. It tells screen readers to announce changes automatically without requiring user interaction (for example, form validation messages or notifications).</p>
<p><strong>Example: Accessible Dropdown (Custom Component)</strong></p>
<pre><code class="language-javascript">function Dropdown({ isOpen, toggle }) {
  return (
    &lt;div&gt;
      &lt;button
        type="button"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onClick={toggle}
      &gt;
        Menu
      &lt;/button&gt;

      {isOpen &amp;&amp; (
        &lt;ul id="dropdown-menu"&gt;
          &lt;li&gt;
            &lt;button type="button" onClick={() =&gt; console.log('Item 1')}&gt;
              Item 1
            &lt;/button&gt;
          &lt;/li&gt;
          &lt;li&gt;
            &lt;button type="button" onClick={() =&gt; console.log('Item 2')}&gt;
              Item 2
            &lt;/button&gt;
          &lt;/li&gt;
        &lt;/ul&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p><strong>How this works:</strong></p>
<ul>
<li><p><code>aria-expanded</code> indicates whether the dropdown is open or closed</p>
</li>
<li><p><code>aria-controls</code> links the button to the dropdown content via its id</p>
</li>
<li><p>The <code>&lt;button&gt;</code> element acts as the trigger and is fully keyboard accessible</p>
</li>
<li><p>The <code>&lt;ul&gt;</code> and <code>&lt;li&gt;</code> elements provide a natural list structure</p>
</li>
<li><p>Using <code>&lt;a&gt;</code> elements ensures proper navigation behavior and accessibility</p>
</li>
</ul>
<p>Why this approach is correct:</p>
<ul>
<li><p>It follows standard web patterns instead of application-style menus</p>
</li>
<li><p>It avoids misusing ARIA roles like role="menu", which require complex keyboard handling</p>
</li>
<li><p>Screen readers can correctly interpret the structure without additional roles</p>
</li>
<li><p>It keeps the implementation simple, accessible, and maintainable</p>
</li>
</ul>
<p>If you need advanced menu behavior (like arrow key navigation), then ARIA menu roles may be appropriate –&nbsp;but only when fully implemented according to the ARIA Authoring Practices.</p>
<p>Note: Most dropdowns in web applications are not true "menus" in the ARIA sense. Avoid using role="menu" unless you are implementing full keyboard navigation (arrow keys, focus management, and so on).</p>
<h2 id="heading-keyboard-navigation">Keyboard Navigation</h2>
<p>Keyboard navigation ensures that users can fully interact with your application using only a keyboard, without relying on a mouse. This is essential for users with motor disabilities, but it also benefits power users and developers who prefer keyboard-based workflows.</p>
<p>In a well-designed interface, users should be able to:</p>
<ul>
<li><p>Navigate through interactive elements using the Tab key</p>
</li>
<li><p>Activate buttons and links using Enter or Space</p>
</li>
<li><p>Clearly see which element is currently focused</p>
</li>
</ul>
<p>In the example below, we’ll look at common mistakes in keyboard handling and why relying on native HTML elements is usually the better approach.</p>
<p><strong>Example:</strong></p>
<p>Avoid adding custom keyboard handlers to native elements like <code>&lt;button&gt;</code>, as they already support keyboard interaction by default.</p>
<p>For example, this is all you need:</p>
<pre><code class="language-html">&lt;button type="button" onClick={handleClick}&gt;Submit&lt;/button&gt;
</code></pre>
<p>This automatically supports:</p>
<ul>
<li><p>Enter and Space key activation</p>
</li>
<li><p>Focus management</p>
</li>
<li><p>Screen reader announcements</p>
</li>
</ul>
<p>Adding manual keyboard event handlers here is unnecessary and can introduce bugs or inconsistent behavior.</p>
<p><strong>What this example shows:</strong></p>
<p>Avoid manually handling keyboard events for native interactive elements like <code>&lt;button&gt;</code>. These elements already provide built-in keyboard support and accessibility features.</p>
<p>For example:</p>
<pre><code class="language-html">&lt;button type="button" onClick={handleClick}&gt;Submit&lt;/button&gt;
</code></pre>
<p>Why this works:</p>
<ul>
<li><p>Supports both Enter and Space key activation by default</p>
</li>
<li><p>Is focusable and participates in natural tab order</p>
</li>
<li><p>Provides built-in accessibility roles and screen reader announcements</p>
</li>
<li><p>Reduces the need for additional logic or ARIA attributes</p>
</li>
</ul>
<p>Adding custom keyboard handlers (like onKeyDown) to native elements is unnecessary and can introduce bugs or inconsistent behavior. Always prefer native HTML elements for interactivity whenever possible.</p>
<h3 id="heading-avoiding-common-keyboard-traps">Avoiding Common Keyboard Traps</h3>
<p>One of the most common keyboard accessibility issues is “trapping users inside interactive components”, such as modals or custom dropdowns. This happens when focus is moved into a component but can't escape using Tab, Shift+Tab, or other keyboard controls. Users relying on keyboards may become stuck, unable to navigate to other parts of the page.</p>
<p>In the example below, you'll see a simple modal that tries to set focus, but doesn’t manage Tab behavior properly.</p>
<pre><code class="language-javascript">function Modal({ isOpen }) {
  const ref = React.useRef();

  React.useEffect(() =&gt; {
    if (isOpen) ref.current?.focus();
  }, [isOpen]);

  return (
    &lt;div role="dialog"&gt;
      &lt;button type="button" ref={ref}&gt;Close&lt;/button&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>What this code shows:</p>
<ul>
<li><p>When the modal opens, focus is moved to the Close button using <code>ref.current.focus()</code></p>
</li>
<li><p>The modal uses <code>role="dialog"</code> to communicate its purpose</p>
</li>
</ul>
<p>There are some issues with this code that you should be aware of. First, tabbing inside the modal may allow focus to move outside the modal if additional focusable elements exist.</p>
<p>Users may also become trapped if no mechanism returns focus to the triggering element when the modal closes.</p>
<p>There's also no handling of Shift+Tab or cycling focus is present.</p>
<p>This demonstrates a <strong>partial focus management</strong>, but it’s not fully accessible yet.</p>
<p>To improve focus management, you can trap focus within the modal by ensuring that Tab and Shift+Tab cycle only through elements inside the modal.</p>
<p>You can also return focus to the trigger: when the modal closes, return focus to the element that opened it.</p>
<p><strong>Example improvement (conceptual):</strong></p>
<pre><code class="language-javascript">function Modal({ isOpen, onClose, triggerRef }) {
  const modalRef = React.useRef();

  React.useEffect(() =&gt; {
    if (isOpen) {
      modalref.current?.focus();
      // Add focus trap logic here
    } else {
      triggerref.current?.focus();
    }
  }, [isOpen]);

  return (
    &lt;div role="dialog" ref={modalRef} tabIndex={-1}&gt;
      &lt;button type="button" onClick={onClose}&gt;Close&lt;/button&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Remember that this modal is not fully accessible without focus trapping. In production, use a library like <code>focus-trap-react</code>, <code>react-aria</code>, or Radix UI.</p>
<p><strong>Key points:</strong></p>
<ul>
<li><p><code>tabIndex={-1}</code> allows the div to receive programmatic focus</p>
</li>
<li><p>Focus trap ensures users cannot tab out unintentionally</p>
</li>
<li><p>Returning focus preserves context, so users can continue where they left off</p>
</li>
</ul>
<p><strong>Best practices:</strong></p>
<ul>
<li><p>Always move focus into modals</p>
</li>
<li><p>Return focus to the trigger element when closed</p>
</li>
<li><p>Ensure Tab cycles correctly</p>
</li>
</ul>
<p>As a general rule, always prefer native HTML elements for interactivity. Only implement custom keyboard handling when building advanced components that cannot be achieved with standard elements.</p>
<h2 id="heading-focus-management">Focus Management</h2>
<p>Focus management is the practice of controlling where keyboard focus goes when users interact with components such as modals, forms, or interactive widgets. Proper focus management ensures that:</p>
<ul>
<li><p>Users relying on keyboards or assistive technologies can navigate seamlessly</p>
</li>
<li><p>Focus does not get lost or trapped in unexpected places</p>
</li>
<li><p>Users maintain context when content updates dynamically</p>
</li>
</ul>
<p>The example below shows a common approach that only partially handles focus:</p>
<p><strong>Bad Example:</strong></p>
<pre><code class="language-javascript">// Bad Example: Automatically focusing input without context
const ref = React.useRef();
React.useEffect(() =&gt; {
  ref.current?.focus();
}, []);
&lt;input ref={ref} placeholder="Name" /&gt;
</code></pre>
<p>In the above code, the input receives focus as soon as the component mounts, but there’s no handling for returning focus when the user navigates away.</p>
<p>If this input is inside a modal or dynamic content, users may get lost or trapped. There aren't any focus indicators or context for assistive technologies.</p>
<p>This is a minimal solution that can cause confusion in real applications.</p>
<p><strong>Improved Example:</strong></p>
<pre><code class="language-javascript">// Improved Example: Managing focus in a modal context
function Modal({ isOpen, onClose, triggerRef }) {  
const dialogRef = React.useRef();

  React.useEffect(() =&gt; {
    if (isOpen) {
      dialogRef.current?.focus();
    } else if (triggerRef?.current) {
      triggerref.current?.focus();
    }
  }, [isOpen]);

  React.useEffect(() =&gt; {
    function handleKeyDown(e) {
      if (e.key === 'Escape') {
        onClose();
      }
    }

    if (isOpen) {
      document.addEventListener('keydown', handleKeyDown);
    }

    return () =&gt; {
      document.removeEventListener('keydown', handleKeyDown);
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    &lt;div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      tabIndex={-1}
      ref={dialogRef}
    &gt;
      &lt;h2 id="modal-title"&gt;Modal Title&lt;/h2&gt;
      &lt;button type="button" onClick={onClose}&gt;Close&lt;/button&gt;
      &lt;input type="text" placeholder="Name" /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p><strong>Explanation:</strong></p>
<ul>
<li><p><code>tabIndex={-1}</code> enables the dialog container to receive focus</p>
</li>
<li><p>Focus is moved to the modal when it opens, ensuring keyboard users start in the correct context</p>
</li>
<li><p>Focus is returned to the trigger element when the modal closes, preserving user flow</p>
</li>
<li><p><code>aria-labelledby</code> provides an accessible name for the dialog</p>
</li>
<li><p>Escape key handling allows users to close the modal without a mouse</p>
</li>
</ul>
<p>Note: For full accessibility, you should also implement focus trapping so users cannot tab outside the modal while it is open.</p>
<p>Tip: In production applications, use libraries like react-aria, focus-trap-react, or Radix UI to handle focus trapping and accessibility edge cases reliably.</p>
<p>Also, keep in mind here that the document-level keydown listener is global, which affects the entire page and can conflict with other components.</p>
<pre><code class="language-javascript">document.addEventListener('keydown', handleKeyDown);
</code></pre>
<p>A safer alternative is to scope it to the modal:</p>
<pre><code class="language-javascript">&lt;div
  onKeyDown={(e) =&gt; {
    if (e.key === 'Escape') onClose();
  }}
&gt;
</code></pre>
<p>For simple cases, attach <code>onKeyDown</code> to the dialog instead of the document.</p>
<h4 id="heading-best-practice">Best Practice:</h4>
<p>For complex components, use libraries like <code>focus-trap-react</code> or <code>react-aria</code> to manage focus reliably, especially for modals, dropdowns, and popovers.</p>
<h2 id="heading-forms-and-accessibility">Forms and Accessibility</h2>
<p>Forms are critical points of interaction in web applications, and proper accessibility ensures that all users – including those using screen readers or other assistive technologies – can understand and interact with them effectively.</p>
<p>Proper labeling means that every input field, checkbox, radio button, or select element has an associated label that clearly describes its purpose. This allows screen readers to announce the input meaningfully and helps keyboard-only users understand what information is expected.</p>
<p>In addition to labeling, form accessibility includes:</p>
<ul>
<li><p>Providing clear error messages when input is invalid</p>
</li>
<li><p>Ensuring error messages are announced to assistive technologies</p>
</li>
<li><p>Maintaining logical focus order so users can navigate inputs easily</p>
</li>
</ul>
<p><strong>Bad Example:</strong></p>
<pre><code class="language-html">&lt;input type="text" placeholder="Name" /&gt;
</code></pre>
<p>Why this isn't good:</p>
<ul>
<li><p>This input relies only on a placeholder for context</p>
</li>
<li><p>Screen readers may not announce the purpose of the field clearly</p>
</li>
<li><p>Once a user starts typing, the placeholder disappears, leaving no guidance</p>
</li>
<li><p>Keyboard-only users may not have enough context to know what to enter</p>
</li>
</ul>
<p><strong>Good Example:</strong></p>
<pre><code class="language-html">&lt;label htmlFor="name"&gt;Name&lt;/label&gt;
&lt;input id="name" type="text" /&gt;
</code></pre>
<p>Why this is better:</p>
<ul>
<li><p>The <code>&lt;label&gt;</code> is explicitly associated with the input via <code>htmlFor / id</code></p>
</li>
<li><p>Screen readers announce "Name" before the input, providing clear context</p>
</li>
<li><p>Users navigating with Tab understand the field’s purpose</p>
</li>
<li><p>The label persists even when the user types, unlike a placeholder</p>
</li>
</ul>
<p><strong>Error Handling:</strong></p>
<pre><code class="language-html">&lt;label htmlFor="name"&gt;Name&lt;/label&gt;
&lt;input
  id="name"
  type="text"
  aria-describedby="name-error"
  aria-invalid="true"
/&gt;

&lt;p id="name-error" role="alert"&gt;
  Name is required
&lt;/p&gt;
</code></pre>
<p><strong>Explanation</strong></p>
<ul>
<li><p><code>aria-describedby</code> links the input to the error message using the element’s id</p>
</li>
<li><p>Screen readers announce the error message when the input is focused</p>
</li>
<li><p><code>aria-invalid="true"</code> indicates that the field currently contains an error</p>
</li>
<li><p><code>role="alert"</code> ensures the error message is announced immediately when it appears</p>
</li>
</ul>
<p>This creates a clear relationship between the input and its validation message, improving usability for screen reader users.</p>
<p>Tip: Only apply aria-invalid and error messages when validation fails. Avoid marking fields as invalid before user interaction.</p>
<h2 id="heading-responsive-typography-and-images">Responsive Typography and Images</h2>
<p>Responsive typography and images ensure that your content remains readable and visually appealing across a wide range of devices, from small smartphones to large desktop monitors.</p>
<p>This is important, because text should scale naturally so it remains legible on all screens, and images should adjust to container sizes to avoid layout issues or overflow. Both contribute to a better user experience and accessibility</p>
<p>In this section, we’ll cover practical ways to implement responsive typography and images in React and CSS.</p>
<pre><code class="language-css">h1 {
  font-size: clamp(1.5rem, 2vw, 3rem);
}
</code></pre>
<p>In this code:</p>
<ul>
<li><p>The <code>clamp()</code> function allows text to scale fluidly:</p>
</li>
<li><p>The first value (1.5rem) is the “minimum font size”</p>
</li>
<li><p>The second value (2vw) is the “preferred size based on viewport width”</p>
</li>
<li><p>The third value (3rem) is the “maximum font size”</p>
</li>
<li><p>This ensures headings are “readable on small screens” without becoming too large on desktops</p>
</li>
</ul>
<p>Alternative methods include using <code>media queries</code> to adjust font sizes at different breakpoints</p>
<p><strong>Responsive Images:</strong></p>
<pre><code class="language-html">&lt;img src="image.jpg" alt="Description" loading="lazy" /&gt;
</code></pre>
<p>In this code, responsive images adapt to different screen sizes and resolutions to prevent layout issues or slow loading times. Key techniques include:</p>
<h3 id="heading-1-fluid-images-using-css">1. Fluid images using CSS:</h3>
<pre><code class="language-css">img {
     max-width: 100%;
     height: auto;
   }
</code></pre>
<p>This makes sure that images never overflow their container and maintains aspect ratio automatically.</p>
<h3 id="heading-2-using-srcset-for-multiple-resolutions">2. Using <code>srcset</code> for multiple resolutions:</h3>
<pre><code class="language-html">&lt;img src="image-small.jpg"
     srcset="image-small.jpg 480w,
             image-medium.jpg 1024w,
             image-large.jpg 1920w"
     sizes="(max-width: 600px) 480px,
            (max-width: 1200px) 1024px,
            1920px"
     alt="Description"&gt;
</code></pre>
<p>This provides different image files depending on screen size or resolution and reduces loading times and improves performance on smaller devices.</p>
<h3 id="heading-3-always-include-descriptive-alt-text">3. Always include descriptive alt text</h3>
<p>This is critical for screen readers and accessibility. It also helps users understand the image if it cannot be loaded.</p>
<p>Tip: Combine responsive typography, images, and flexible layout containers (like CSS Grid or Flexbox) to create interfaces that scale gracefully across all devices and maintain accessibility.</p>
<h3 id="heading-4-ensure-sufficient-color-contrast">4. Ensure Sufficient Color Contrast</h3>
<p>Low contrast text can make content unreadable for many users.</p>
<pre><code class="language-css">.bad-text {
  color: #aaa;
}

.good-text {
  color: #222;
}
</code></pre>
<p>Use tools like WebAIM Contrast Checker and Chrome DevTools Accessibility panel to check your color contrasts. Also note that WCAG AA requires 4.5:1 contrast ratio for normal text.</p>
<h2 id="heading-building-a-fully-accessible-responsive-component-end-to-end-example">Building a Fully Accessible Responsive Component (End-to-End Example)</h2>
<p>To understand how responsiveness and accessibility work together in practice, let’s build a reusable accessible card component that adapts to screen size and supports keyboard and screen reader users.</p>
<h3 id="heading-step-1-component-structure-semantic-html">Step 1: Component Structure (Semantic HTML)</h3>
<pre><code class="language-javascript">function ProductCard({ title, description, onAction }) {
  return (
    &lt;article className="card"&gt;
      &lt;h3&gt;{title}&lt;/h3&gt;
      &lt;p&gt;{description}&lt;/p&gt;
      &lt;button type="button" onClick={onAction}&gt;
        View Details
      &lt;/button&gt;
    &lt;/article&gt;
  );
}
</code></pre>
<p><strong>Why This Works</strong></p>
<ul>
<li><p><code>&lt;article&gt;</code> provides semantic meaning for standalone content</p>
</li>
<li><p><code>&lt;h3&gt;</code> establishes a proper heading hierarchy</p>
</li>
<li><p><code>&lt;button&gt;</code> ensures built-in keyboard and accessibility support</p>
</li>
</ul>
<h3 id="heading-step-2-responsive-styling">Step 2: Responsive Styling</h3>
<pre><code class="language-css">.card {
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

@media (min-width: 768px) {
  .card {
    padding: 24px;
  }
}
</code></pre>
<p>This ensures comfortable spacing on mobile and improved readability on larger screens.</p>
<h3 id="heading-step-3-accessibility-enhancements">Step 3: Accessibility Enhancements</h3>
<pre><code class="language-html">&lt;button type="button" onClick={onAction}&gt;
  View Details
&lt;/button&gt;
</code></pre>
<p>The visible button text provides a clear and accessible label, so no additional ARIA attributes are needed.</p>
<h3 id="heading-step-4-keyboard-focus-styling">Step 4: Keyboard Focus Styling</h3>
<pre><code class="language-css">button:focus {
  outline: 2px solid blue;
  outline-offset: 2px;
}
</code></pre>
<p>Focus indicators are essential for keyboard users.</p>
<h3 id="heading-step-5-using-the-component">Step 5: Using the Component</h3>
<pre><code class="language-javascript">function App() {
  return (
    &lt;div className="grid"&gt;
      &lt;ProductCard
        title="Product 1"
        description="Accessible and responsive"
        onAction={() =&gt; alert('Clicked')}
      /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p><strong>Key Takeaways</strong></p>
<p>This simple component demonstrates:</p>
<ul>
<li><p>Semantic HTML structure</p>
</li>
<li><p>Responsive design</p>
</li>
<li><p>Built-in accessibility via native elements</p>
</li>
<li><p>Minimal ARIA usage</p>
</li>
</ul>
<p>In real-world applications, this pattern scales into entire design systems.</p>
<h2 id="heading-testing-accessibility">Testing Accessibility</h2>
<p>Accessibility should be validated continuously, not just at the end of development. There are various automated tools you can use to help you with this process:</p>
<ul>
<li><p>Lighthouse (built into Chrome DevTools)</p>
</li>
<li><p>axe DevTools for detailed audits</p>
</li>
<li><p>ESLint plugins for accessibility rules</p>
</li>
</ul>
<h3 id="heading-manual-testing">Manual Testing</h3>
<p>But automated tools cannot catch everything. Manual testing is essential to make sure users can navigate using only the keyboard and use a screen reader (NVDA or VoiceOver. You should also test zoom levels (up to 200%) and check the color contrast manually.</p>
<p><strong>Example: ESLint Accessibility Plugin</strong></p>
<pre><code class="language-shell">npm install eslint-plugin-jsx-a11y --save-dev
</code></pre>
<p>This helps catch accessibility issues during development.</p>
<h2 id="heading-best-practices">Best Practices</h2>
<ul>
<li><p>Use semantic HTML first</p>
</li>
<li><p>Avoid unnecessary ARIA</p>
</li>
<li><p>Test keyboard navigation</p>
</li>
<li><p>Design mobile-first</p>
</li>
<li><p>Ensure color contrast</p>
</li>
<li><p>Use consistent spacing</p>
</li>
</ul>
<h2 id="heading-when-not-to-overuse-accessibility-features">When NOT to Overuse Accessibility Features</h2>
<ul>
<li><p>Avoid adding ARIA when native HTML works</p>
</li>
<li><p>Do not override browser defaults unnecessarily</p>
</li>
<li><p>Avoid complex custom components without accessibility support</p>
</li>
</ul>
<h2 id="heading-future-enhancements">Future Enhancements</h2>
<ul>
<li><p>Design systems with accessibility built-in</p>
</li>
<li><p>Automated accessibility testing in CI/CD</p>
</li>
<li><p>Advanced focus management libraries</p>
</li>
<li><p>Accessibility-first component libraries</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building responsive and accessible React applications is not a one-time effort—it is a continuous design and engineering practice. Instead of treating accessibility as a checklist, developers should integrate it into the core of their component design process.</p>
<p>If you are starting out, focus on using semantic HTML and mobile-first layouts. These two practices alone solve a large percentage of accessibility and responsiveness issues. As your application grows, introduce ARIA enhancements, keyboard navigation, and automated accessibility testing.</p>
<p>The key is to build interfaces that work for everyone by default. When responsiveness and accessibility are treated as first-class concerns, your React applications become more usable, scalable, and future-proof.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Interactive University Ranking System Using React and Data Viz Tools ]]>
                </title>
                <description>
                    <![CDATA[ Hi! I'm Daria, and I'm a software engineering student with a keen interest in data visualization. I've been actively exploring various visualization tools through small pet projects, and I'd like to s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-an-interactive-ranking-system-using-react-and-data-viz-tools/</link>
                <guid isPermaLink="false">69c44d7d10e664c5daefbf5c</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #data visualisation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ charts ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pivottable ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Daria Filozop ]]>
                </dc:creator>
                <pubDate>Wed, 25 Mar 2026 21:02:53 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/5f31dd6f-e199-4cf3-a76a-daf985a4e7d7.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Hi! I'm Daria, and I'm a software engineering student with a keen interest in data visualization. I've been actively exploring various visualization tools through small pet projects, and I'd like to share my latest demo with you.</p>
<p>In this tutorial, we'll build a project that displays the historical rankings of universities around the world. It's interesting to check out and analyze which institutions consistently maintain their top-tier positions and which ones experience significant movement up or down the rankings over the years.</p>
<p>While building the dashboard, we'll load and structure the dataset, display all important information in a pivot table, and then create charts to visualize the top 10 universities and their ranking trends over time.</p>
<p>Even though we'll walk through this specific example here, you can apply this same approach to many other datasets to build data viz dashboards.</p>
<h2 id="heading-what-well-cover">What We’ll Cover:</h2>
<ol>
<li><p><a href="#heading-tech-stack">Tech Stack</a></p>
</li>
<li><p><a href="#heading-flexmonster-pivot-table-setup">Flexmonster Pivot Table Setup</a></p>
</li>
<li><p><a href="#heading-loading-and-displaying-the-data">Loading and Displaying the Data</a></p>
</li>
<li><p><a href="#heading-creating-charts">Creating Charts</a></p>
</li>
<li><p><a href="#heading-adding-year-filtering-buttons">Adding Year Filtering Buttons</a></p>
</li>
<li><p><a href="#heading-styling">Styling</a></p>
</li>
</ol>
<h2 id="heading-tech-stack">Tech Stack</h2>
<p>Let’s talk about the tools we’ll be using so you know what you’ll need to have before following along.</p>
<p>First, we’ll use <a href="https://react.dev/"><strong>React</strong></a>, which is a popular JavaScript library for building interactive web interfaces. It helps create reusable components and manage data efficiently. According to <a href="https://survey.stackoverflow.co/2025/technology"><strong>the 2025 Stack Overflow Developer Survey</strong></a>, React is the second most popular web framework.</p>
<p>We’ll also use <a href="https://www.flexmonster.com/"><strong>Flexmonster Pivot Table</strong></a>, a web component for displaying data in a table format. In general, pivot tables are widely used for data visualization because they allow you to quickly group, aggregate, filter, and explore large datasets from different perspectives.</p>
<p>With Flexmonster, you can easily create reports and customize your information. It also integrates smoothly with almost all popular modern frameworks (like React, which we’re using here!).</p>
<p>Next, we have <a href="https://echarts.apache.org/en/index.html"><strong>ECharts</strong></a>, which complements the detailed data provided by the pivot table. It’s a powerful, open-source charting library that’ll give us the visual insights we need, offering over 20 chart types to effectively visualize historical university ranking trends.</p>
<p>Finally, we’ll use the <a href="https://www.kaggle.com/datasets/mylesoneill/world-university-rankings"><strong>World University Rankings Dataset</strong></a>. It contains data from three global rankings (<a href="https://www.timeshighereducation.com/world-university-rankings"><strong>THE</strong></a>, <a href="https://www.shanghairanking.com/"><strong>ARWU</strong></a>, and <a href="https://cwur.org/"><strong>CWUR</strong></a>), providing information about well-known universities from 2012 to 2015 for detailed analytical research. The dataset size is 186.38 kB.</p>
<p>As a small disclaimer, this tutorial uses React, so some familiarity with it will help you follow along. But actually, you can use any other framework that's convenient for you. Flexmonster Pivot Table offers <a href="https://www.flexmonster.com/doc/available-tutorials-integration/">many integrations with popular frameworks</a>, including Angular, Vue, Svelte, and more.</p>
<p>When we’re done with the project, we’ll get an interactive dashboard like this:</p>
<img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXe49D_OM3dE-Q8bLmhsYyejXE0CF0PZTZeXhm_3vJfCDOO8JmQMCbLk8x_d_FBAWJjcsKFd0Mm3uTntW2zcp6NYGMXUVnyfjpwWI0izXInLvUhcHgtoSPoCt4EFtHpVm5WOzP-khwnxPryrgO1FjEUqWXSEWtI?key=44ff_UPj0hN24gZs8A8Dew" alt="Interactive dashboard for visualizing university ratings" width="600" height="400" loading="lazy">

<p>So now that you’re familiar with the tools, let’s get started!</p>
<h2 id="heading-flexmonster-pivot-table-setup">Flexmonster Pivot Table Setup</h2>
<p>To get started, you’ll need to integrate Flexmonster into your React project. I’ll walk you through how it works with React, but you can use other frameworks, too. You can find complete instructions in <a href="https://www.flexmonster.com/doc/available-tutorials-integration/"><strong>the Flexmonster docs</strong></a>.</p>
<p>First, create a React application using Vite:</p>
<pre><code class="language-shell">npm create vite@latest flexmonster-project -- --template react
</code></pre>
<p>Also, don’t forget to install npm dependencies:</p>
<pre><code class="language-shell">cd flexmonster-project
npm install
</code></pre>
<p>Next, we’ll install the Flexmonster wrapper for React. To do this, use this command:</p>
<pre><code class="language-shell">npm install -g flexmonster-cli
flexmonster add react-flexmonster
</code></pre>
<p>Then add Flexmonster styles and the component to your App.jsx file:</p>
<pre><code class="language-javascript">import FlexmonsterReact from "react-flexmonster";
import "flexmonster/flexmonster.css";
</code></pre>
<h2 id="heading-loading-and-displaying-the-data">Loading and Displaying the Data</h2>
<p>Now it’s time to create a report object. This is a configuration that defines how the pivot table should load and display data. It describes how the fields should be interpreted, and how the table should organize and aggregate the information.</p>
<p>The first part of this is <code>dataSource</code>. It defines where the data comes from and how it should be read by the pivot table (format of the dataset, location of the file, and the structure of the fields that will be used).</p>
<p>In our case, we'll load a CSV file that contains the university rankings dataset and define the fields that will appear in the pivot table:</p>
<pre><code class="language-javascript">&nbsp;&nbsp;const report = {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;dataSource: {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;type: "csv",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;filename: "/data/world-university-rankings.csv",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;mapping: {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;world_rank: { type: "number", caption: "World Rank" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;institution: { type: "string" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;country: { type: "string" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;score: { type: "number", caption: "Score" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;year: { type: "number", caption: "Year" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;},
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;},
…
}
</code></pre>
<p>The second part is the slice section. It defines which subset of the dataset will be displayed and how it should be organized. There, you'll render a table by setting measures, rows, and columns. You can also set the <code>flatOrder</code> property, where you define the order of the fields in the flat form.</p>
<p>You can find more detailed information about the <a href="https://www.flexmonster.com/api/slice-object/"><strong>Slice Object here</strong></a>. There are lots of interesting functional possibilities!</p>
<pre><code class="language-javascript">const report = {
…
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;slice: {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;rows: [{ uniqueName: "institution" }],
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;columns: [{ uniqueName: "[Measures]" }],
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;measures: [
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ uniqueName: "world_rank", aggregation: "min" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;uniqueName: "year",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;aggregation: "none",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;filter: { members: [`year.[${selectedYear}]`] },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;},
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;],
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;flatOrder: ["institution", "world_rank"],
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;},
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;options: {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;grid: { type: "flat", showGrandTotals: "off" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;},
&nbsp;&nbsp;&nbsp;&nbsp;};
</code></pre>
<p>You’ll also need to include Flexmonster inside the React component. In this example, we'll include the <code>FlexmonsterReact</code> tag in JSX and pass the report object (which we defined earlier) as a property. You can do that with this code snippet:</p>
<pre><code class="language-javascript">&lt;FlexmonsterReact
&nbsp;&nbsp;&nbsp;&nbsp;ref={pivotRef}
&nbsp;&nbsp;&nbsp;&nbsp;toolbar={true}
&nbsp;&nbsp;&nbsp;&nbsp;report={report}
&nbsp;&nbsp;&nbsp;&nbsp;width="100%"
&nbsp;&nbsp;&nbsp;&nbsp;height="100%"
&nbsp;&nbsp;&nbsp;&nbsp;reportcomplete={createChart}
/&gt;
</code></pre>
<p>As a result, we've got pivot table with all the info about the universities:</p>
<img src="https://cdn.hashnode.com/uploads/covers/690da61ea51d4259bd4a849b/ab96b9fc-c322-482c-b95a-33d17887a94e.png" alt="Pivot table with university data and rankings" style="display:block;margin:0 auto" width="2792" height="942" loading="lazy">

<h2 id="heading-creating-charts">Creating Charts</h2>
<p>For some users, charts are easier to understand than tables, so let’s also create some now. I decided to display the top 10 universities in the world using bar charts. Bar charts are commonly used to compare values between categories, and they're quite useful for highlighting rankings or top performers.</p>
<p>We'll use ECharts here, but you can easily integrate Flexmonster with <a href="https://www.flexmonster.com/doc/available-tutorials-charts/"><strong>the most convenient chart library for you</strong></a>.</p>
<p>As a first step, make sure to install ECharts in your project:</p>
<pre><code class="language-shell">npm install echarts
</code></pre>
<p>To prepare our data for display in the charts, we’ll create the <code>prepareData()</code> function. This function picks out the universities' names and their ranks, removes any invalid data, sorts it by rank, and keeps only the top 10. It returns two arrays: one with the names (for chart labels) and another with the rankings (for chart values):</p>
<pre><code class="language-javascript">const prepareData = (rawData) =&gt; {
&nbsp;&nbsp;&nbsp;&nbsp;const rows = rawData.data
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.map((r) =&gt; ({ name: r.r0, rank: r.v0 }))
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.filter((r) =&gt; r.name &amp;&amp; !isNaN(r.rank))
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.sort((a, b) =&gt; a.rank - b.rank)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;.slice(0, 10);
&nbsp;&nbsp;&nbsp;&nbsp;return {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;labels: rows.map((r) =&gt; r.name).reverse(),
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;values: rows.map((r) =&gt; r.rank).reverse(),
&nbsp;&nbsp;&nbsp;&nbsp;};
};
</code></pre>
<p>Next, we'll set up chart options. You’ll need to decide in what form your dataset will be displayed: in this example, we'll choose a bar chart. We'll also set the title (here "Top 10 Universities by World Rank"), x-axis (shows the rank numbers) and y-axis (shows universities names), and tooltip, which shows info when you hover over a bar.</p>
<p>Also, don't forget to initialize the chart and apply these options. You can do all this with this code snippet:</p>
<pre><code class="language-javascript">const drawChart = (rawData) =&gt; {
&nbsp;&nbsp;&nbsp;&nbsp;const { labels, values } = prepareData(rawData);
&nbsp;&nbsp;&nbsp;&nbsp;const options = {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;title: {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;text: "Top 10 Universities by World Rank",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;left: "center",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;textStyle: { fontSize: 20, fontWeight: "bold" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;},
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;xAxis: { type: "value", name: "Rank" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;yAxis: { type: "category", data: labels },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;series: [{ type: "bar", data: values, barMaxWidth: 30 }],
&nbsp;&nbsp;&nbsp;&nbsp;};
&nbsp;&nbsp;&nbsp;&nbsp;chartInstance = echarts.init(chartRef.current);
&nbsp;&nbsp;&nbsp;&nbsp;chartInstance.setOption(options);
};
</code></pre>
<p>Also, an important part of chart configuration is the <code>updateCharts()</code> function, which redraws the chart when needed:</p>
<pre><code class="language-javascript">const updateChart = (rawData) =&gt; {
&nbsp;&nbsp;&nbsp;&nbsp;if (chartInstance) chartInstance.dispose();
&nbsp;&nbsp;&nbsp;&nbsp;drawChart(rawData);
};
</code></pre>
<p>So now we can see the charts we've created! This might look a bit basic and really hard to read, but don’t worry – we’ll make it look nicer and easier to understand in a later section.</p>
<img src="https://cdn.hashnode.com/uploads/covers/690da61ea51d4259bd4a849b/65cf5e06-f0dd-4d45-b1eb-e78797b44d2b.png" alt="Bar charts showing the top 10 universities by world rank" style="display:block;margin:0 auto" width="2642" height="604" loading="lazy">

<h2 id="heading-adding-year-filtering-buttons">Adding Year Filtering Buttons</h2>
<p>You might have noticed that the dataset contains ratings for different years (from 2012 to 2015). It would be great if we could use the whole dataset, not just information for one year.</p>
<p>To manage this, we'll create filtering buttons for each year to provide more straightforward navigation.</p>
<p>First, we’ll add a div element which contains buttons for each year:</p>
<pre><code class="language-javascript">&lt;div className="years-container"&gt;
&nbsp;&nbsp;&nbsp;&nbsp;{[2012, 2013, 2014, 2015].map((year) =&gt; (
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;button
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;key={year}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;onClick={() =&gt; handleYearChange(year)}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;className={`year-btn ${selectedYear === year ? "active" : ""}`}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&gt;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{year}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;/button&gt;
&nbsp;&nbsp;&nbsp;&nbsp;))}
&lt;/div&gt;
</code></pre>
<p>In the above code snippet, you can see that each button calls <code>handleYearChange(year)</code> when being clicked. Let’s now examine what this handler does:</p>
<pre><code class="language-javascript">const handleYearChange = (year) =&gt; {
&nbsp;&nbsp;&nbsp;&nbsp;setSelectedYear(year);

&nbsp;&nbsp;&nbsp;&nbsp;const pivot = pivotRef.current?.flexmonster;
&nbsp;&nbsp;&nbsp;&nbsp;if (pivot) {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;const newReport = {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;...report,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;slice: {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;...report.slice,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;measures: [
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{ uniqueName: "world_rank", aggregation: "min" },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;{
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;uniqueName: "year",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;aggregation: "none",
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;filter: { members: [`year.[${year}]`] },
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;},
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;],
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;},
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;};
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;pivot.setReport(newReport);
&nbsp;&nbsp;&nbsp;&nbsp;}
};&nbsp;
</code></pre>
<p>This function modifies the pivot table report to filter by that year, and refreshes the table. This way, clicking a button instantly shows only the data for the chosen year.</p>
<p>And now we've got buttons which display years from our dataset:</p>
<img src="https://cdn.hashnode.com/uploads/covers/690da61ea51d4259bd4a849b/184608a5-6207-44d8-b5fe-80fd355201c0.png" alt="Buttons to filter data by year" style="display:block;margin:0 auto" width="571" height="99" loading="lazy">

<h2 id="heading-styling">Styling</h2>
<p>Finally, here’s my favorite part of every project: customization! I love experimenting with different styles and choosing the most appropriate one.</p>
<p>For this dashboard, I chose light violet and white colors to make the interface clean and easy to read. I personally associate the university vibe with these colors, so I think they match our dashboard perfectly. So let's go step by step through it.</p>
<p>The main container defines the overall layout and background. It centers the content on the page, adds some spacing, and sets the base font and colors.</p>
<pre><code class="language-javascript">.app-container {
    min-height: 100vh;
    width: 100vw;
    padding: 32px;
    display: flex;
    flex-direction: column;
    align-items: center;

    background: #ebe6f7;
    font-family: "Inter", system-ui, -apple-system, sans-serif;
    color: #1b2a4e;
}
</code></pre>
<p>Next, we'll style the year filter buttons. They have rounded corners, a soft shadow, and a small hover effect so the interface feels interactive. The active button gets a violet background so it’s easy to see which year is selected.</p>
<pre><code class="language-javascript">.year-btn {
    padding: 10px 20px;
    background: #ffffff;
    border: 1px solid #cdd2e0;
    border-radius: 8px;
    cursor: pointer;
}

.year-btn:hover {
    background: #e1dffa;
}

.year-btn.active {
    background: #6c5ce7;
    color: white;
}
</code></pre>
<p>Also, the pivot table and the chart are located inside simple containers with rounded corners and slight shadows. This visually separates the components and keeps the layout logically structured.</p>
<pre><code class="language-javascript">.pivot-container,
.chart-container {
    width: 90%;
    background: #ffffff;
    border-radius: 12px;
    padding: 14px;
    box-shadow: 0 6px 20px rgba(15, 35, 95, 0.12);
}

.pivot-container {
    height: 56vh;
    margin-bottom: 24px;
}

.chart-container {
    height: 40vh;
}
</code></pre>
<p>And now, here's the result of our work!</p>
<img src="https://cdn.hashnode.com/uploads/covers/690da61ea51d4259bd4a849b/ed35dbdd-9c5d-4f7e-b6e1-4f0546a0d660.png" alt="Universities ranking pivot table with interactive year filters" style="display:block;margin:0 auto" width="2826" height="1130" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/690da61ea51d4259bd4a849b/1a0b0f1c-9c26-40e2-8c28-bd45517ea145.png" alt="Charts showing the top 10 universities by world rank" style="display:block;margin:0 auto" width="2826" height="698" loading="lazy">

<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>In this tutorial, we've build an interactive dashboard for visualizing the world's top university rankings over the years. You learned how to load and show data in pivot table in a convenient and compact way, make bar charts to show comparative data, and add buttons to filter info by year.</p>
<p>You can now use these skills to visualize other datasets and play with different charts and customization options.</p>
<p>If you want to look closer at my code and get detailed styling code, you can check out my GitHub: <a href="https://github.com/filozopdasha/universities-dashboard"><strong>https://github.com/filozopdasha/universities-dashboard</strong></a></p>
<p>I would be delighted to hear your thoughts about this small project. I’m curious to see what you build!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use OpenStreetMap as a Free Alternative to Google Maps ]]>
                </title>
                <description>
                    <![CDATA[ Google Maps has been the default choice for developers building location-based applications for years. But for many teams, especially those operating at scale, pricing has become a real concern. Googl ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-openstreetmap-free-alternative-to-google-maps/</link>
                <guid isPermaLink="false">69c41cdf10e664c5dacd6389</guid>
                
                    <category>
                        <![CDATA[ #LocationServices  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ maps ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Aiyedogbon Abraham ]]>
                </dc:creator>
                <pubDate>Wed, 25 Mar 2026 17:35:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0ab3655a-4212-451d-93e1-5c707ed1b07e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Google Maps has been the default choice for developers building location-based applications for years. But for many teams, especially those operating at scale, pricing has become a real concern.</p>
<p>Google Maps provides a $200 monthly credit, but beyond that, usage is billed per request. For applications like logistics, ride-hailing, or fleet tracking – where thousands of requests are made daily – costs can grow quickly depending on which APIs you use.</p>
<p>OpenStreetMap (OSM) offers a different approach. Instead of charging for access to map APIs, it provides free, open geographic data that you can build on.</p>
<p>In this guide, you'll learn what OpenStreetMap is, how it differs from Google Maps, and how to integrate it into a React application using Leaflet.</p>
<h2 id="heading-what-well-cover">What We'll Cover:</h2>
<ol>
<li><p><a href="#heading-what-is-openstreetmap">What is OpenStreetMap?</a></p>
</li>
<li><p><a href="#heading-why-choose-openstreetmap-over-google-maps">Why Choose OpenStreetMap Over Google Maps?</a></p>
</li>
<li><p><a href="#heading-understanding-the-open-street-map-ecosystem">Understanding the OpenStreetMap Ecosystem</a></p>
<ul>
<li><p><a href="#heading-data-layer-openstreetmap">Data Layer (OpenStreetMap)</a></p>
</li>
<li><p><a href="#heading-rendering-layer-leaflet-maplibre">Rendering Layer (Leaflet, MapLibre)</a></p>
</li>
<li><p><a href="#heading-services-layer">Services Layer</a></p>
</li>
<li><p><a href="#heading-how-everything-works-together">How Everything Works Together</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-integrate-openstreetmap-in-react-with-leaflet">How to Integrate OpenStreetMap in React with Leaflet</a></p>
</li>
<li><p><a href="#heading-how-to-add-geocoding-with-nominatim">How to Add Geocoding with Nominatim</a></p>
</li>
<li><p><a href="#heading-advanced-features">Advanced Features</a></p>
</li>
<li><p><a href="#heading-when-to-choose-openstreetmap-vs-google-maps">When to Choose OpenStreetMap vs Google Maps</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-what-is-openstreetmap">What is OpenStreetMap?</h2>
<p>OpenStreetMap is a free, open, and community-driven map of the world. Anyone can contribute to it, and anyone can use it.</p>
<p>Unlike Google Maps, which gives access through controlled APIs, OpenStreetMap gives you access to the underlying geographic data itself.</p>
<p>This data is structured in three main ways:</p>
<ol>
<li><p><strong>Nodes</strong>: single points (for example, a bus stop or a tree)</p>
</li>
<li><p><strong>Ways</strong>: lines or shapes made up of nodes (like roads or buildings)</p>
</li>
<li><p><strong>Relations</strong>: groups of nodes and ways that define more complex things (like routes or boundaries)</p>
</li>
</ol>
<p>Each of these elements includes tags (key-value pairs), such as:</p>
<pre><code class="language-plaintext">highway=residential
name=Allen Avenue
</code></pre>
<p>So instead of just displaying a map, OpenStreetMap lets you work with structured geographic data.</p>
<h3 id="heading-the-open-database-license-odbl">The Open Database License (ODbL)</h3>
<p>OpenStreetMap data is licensed under the ODbL. This means:</p>
<ul>
<li><p>You can use it for commercial or personal projects</p>
</li>
<li><p>You must give proper attribution</p>
</li>
</ul>
<p>This makes it especially useful for developers who want clarity around data ownership.</p>
<h2 id="heading-why-choose-openstreetmap-over-google-maps">Why Choose OpenStreetMap Over Google Maps?</h2>
<h3 id="heading-cost">Cost</h3>
<p>OpenStreetMap data is free to use. But it's important to be precise here: <strong>OpenStreetMap removes licensing costs, but not infrastructure costs.</strong></p>
<p>You may still need to pay for:</p>
<ul>
<li><p>Tile hosting</p>
</li>
<li><p>Geocoding services</p>
</li>
<li><p>Routing engines</p>
</li>
</ul>
<h3 id="heading-control">Control</h3>
<p>With Google Maps, you can't modify the data, and you rely entirely on Google's APIs</p>
<p>But with OpenStreetMap, you can download and store the data, modify it, and build custom solutions on top of it.</p>
<h3 id="heading-customization">Customization</h3>
<p>OpenStreetMap gives you more flexibility:</p>
<ul>
<li><p>You control how maps are rendered</p>
</li>
<li><p>You can choose or build your own map styles</p>
</li>
<li><p>You can create domain-specific maps</p>
</li>
</ul>
<h3 id="heading-adoption">Adoption</h3>
<p>OpenStreetMap is widely used. Companies like Meta and Microsoft contribute to it, and many platforms rely on it directly or indirectly.</p>
<p>This shows that the ecosystem is mature and reliable.</p>
<h2 id="heading-understanding-the-openstreetmap-ecosystem">Understanding the OpenStreetMap Ecosystem</h2>
<p>A common mistake is to think that OpenStreetMap works like a single API. It doesn't.</p>
<p>Instead, it works as a set of layers, where each layer handles a different responsibility.</p>
<h3 id="heading-data-layer-openstreetmap">Data Layer (OpenStreetMap)</h3>
<p>This is the foundation. It contains all the raw geographic data:</p>
<ul>
<li><p>Roads</p>
</li>
<li><p>Buildings</p>
</li>
<li><p>Landmarks</p>
</li>
<li><p>Boundaries</p>
</li>
</ul>
<p>This is what you are ultimately working with.</p>
<h3 id="heading-rendering-layer-leaflet-maplibre">Rendering Layer (Leaflet, MapLibre)</h3>
<p>Raw data isn't visual. It needs to be turned into something users can see.</p>
<p>There are two main approaches:</p>
<ol>
<li><p><strong>Raster tiles</strong> (used by Leaflet): pre-rendered images</p>
</li>
<li><p><strong>Vector tiles</strong> (used by MapLibre): raw geometry styled in the browser</p>
</li>
</ol>
<p>Leaflet uses raster tiles by default, which makes it simple and fast to start with.</p>
<h3 id="heading-services-layer">Services Layer</h3>
<p>This is what makes your map interactive. <strong>Geocoding</strong> converts addresses into coordinates, while <strong>reverse geocoding</strong> converts coordinates into addresses.</p>
<p><strong>Routing</strong> calculates directions between points, and <strong>tile servers</strong> provide the actual map visuals.</p>
<h3 id="heading-how-everything-works-together">How Everything Works Together</h3>
<p>When a user searches for a place:</p>
<ol>
<li><p>The user enters a location</p>
</li>
<li><p>A geocoding service converts it into coordinates</p>
</li>
<li><p>The map updates its position</p>
</li>
<li><p>A tile server provides the visual map</p>
</li>
</ol>
<p>Each part is separate, but they work together to create the full experience.</p>
<h2 id="heading-how-to-integrate-openstreetmap-in-react-with-leaflet">How to Integrate OpenStreetMap in React with Leaflet</h2>
<p>Let's build a simple map.</p>
<h3 id="heading-step-1-create-a-react-app">Step 1: Create a React App</h3>
<pre><code class="language-bash">npm create vite@latest osm-app -- --template react
cd osm-app
npm install
</code></pre>
<h3 id="heading-step-2-install-dependencies">Step 2: Install Dependencies</h3>
<pre><code class="language-bash">npm install leaflet react-leaflet
npm install --save-dev @types/leaflet
</code></pre>
<h3 id="heading-step-3-import-leaflet-css">Step 3: Import Leaflet CSS</h3>
<pre><code class="language-javascript">import 'leaflet/dist/leaflet.css';
</code></pre>
<p>This is required for the map to display correctly.</p>
<h3 id="heading-step-4-create-a-map-component">Step 4: Create a Map Component</h3>
<pre><code class="language-javascript">import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';

function Map() {
  const position = [51.505, -0.09]; // latitude, longitude

  return (
    &lt;MapContainer
      center={position}
      zoom={13}
      style={{ height: '100vh' }}
    &gt;
      &lt;TileLayer
        attribution='&amp;copy; &lt;a href="https://www.openstreetmap.org/copyright"&gt;OpenStreetMap&lt;/a&gt; contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      /&gt;
      &lt;Marker position={position}&gt;
        &lt;Popup&gt;Hello from OpenStreetMap&lt;/Popup&gt;
      &lt;/Marker&gt;
    &lt;/MapContainer&gt;
  );
}

export default Map;
</code></pre>
<p>Let's break down the important parts here:</p>
<p><code>MapContainer</code> initializes the map.</p>
<ul>
<li><p><code>center</code> is where the map starts</p>
</li>
<li><p><code>zoom</code> is how close the view is</p>
</li>
<li><p><code>style</code> must include height, or the map won't show</p>
</li>
</ul>
<p><code>TileLayer</code> defines where the map visuals come from.</p>
<ul>
<li><p><code>{z}</code> is the zoom level</p>
</li>
<li><p><code>{x}</code>, <code>{y}</code> are the tile coordinates</p>
</li>
<li><p><code>{s}</code> is the subdomain</p>
</li>
</ul>
<p>Each tile is a small image (usually 256×256 pixels), and Leaflet combines them to form the full map.</p>
<p><code>Marker</code> adds a point on the map at a specific coordinate.</p>
<p><code>Popup</code> displays information when the marker is clicked.</p>
<h4 id="heading-important-note">Important note:</h4>
<p>The default OpenStreetMap tile server:</p>
<pre><code class="language-plaintext">https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
</code></pre>
<p>is meant for learning, demos, and low-traffic apps. For production, you should use a dedicated provider or your own tile server.</p>
<h2 id="heading-how-to-add-geocoding-with-nominatim">How to Add Geocoding with Nominatim</h2>
<p>Nominatim is OpenStreetMap's geocoding service. It allows you to convert addresses into coordinates and coordinates into readable locations.</p>
<h3 id="heading-custom-hook-for-geocoding">Custom Hook for Geocoding</h3>
<pre><code class="language-javascript">import { useState } from 'react';

export function useGeocoding() {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const searchAddress = async (query) =&gt; {
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(
        `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&amp;format=json&amp;limit=5`,
        {
          headers: {
            'User-Agent': 'YourAppName/1.0'
          }
        }
      );

      if (!response.ok) {
        throw new Error('Request failed');
      }

      const data = await response.json();
      setLoading(false);
      return data;
    } catch (err) {
      setError(err.message);
      setLoading(false);
      return [];
    }
  };

  return { searchAddress, loading, error };
}
</code></pre>
<p>In this code:</p>
<ul>
<li><p><code>useState</code> manages loading and error states</p>
</li>
<li><p><code>encodeURIComponent</code> ensures safe URLs</p>
</li>
<li><p><code>User-Agent</code> is required by Nominatim</p>
</li>
<li><p><code>response.json()</code> converts response into usable data</p>
</li>
</ul>
<p>Nominatim returns coordinates as strings, so you have to convert them before using them.</p>
<h3 id="heading-important-usage-rules">Important Usage Rules</h3>
<p>The public Nominatim service:</p>
<ul>
<li><p>Allows about 1 request per second</p>
</li>
<li><p>Requires proper identification</p>
</li>
<li><p>May block excessive usage</p>
</li>
</ul>
<p>You should debounce user input, cache results, and avoid repeated requests.</p>
<h3 id="heading-creating-a-search-component">Creating a Search Component</h3>
<p>The search component lets users type an address or place name and get matching locations via Nominatim. It includes a text input and a submit button.</p>
<p>When the form is submitted, it calls our <code>searchAddress</code> function (from the <code>useGeocoding</code> hook), which fetches up to 5 address results. These results are displayed below the input as clickable items.</p>
<p>When the user clicks a result, the component parses the returned latitude and longitude into numbers and passes them (along with a display name) up to the parent component via the <code>onLocationSelect</code> callback. This will allow the parent (for example, the map) to update its center based on the chosen location.</p>
<pre><code class="language-javascript">function SearchBox({ onLocationSelect }) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const { searchAddress, loading } = useGeocoding();

  const handleSearch = async (e) =&gt; {
    e.preventDefault();
    if (!query.trim()) return;

    const data = await searchAddress(query);
    setResults(data);
  };

  const selectLocation = (result) =&gt; {
    onLocationSelect({
      lat: parseFloat(result.lat),
      lon: parseFloat(result.lon),
      name: result.display_name
    });
  };

  return (
    &lt;div&gt;
      &lt;form onSubmit={handleSearch}&gt;
        &lt;input
          value={query}
          onChange={(e) =&gt; setQuery(e.target.value)}
          placeholder="Search location"
        /&gt;
        &lt;button type="submit"&gt;
          {loading ? 'Searching...' : 'Search'}
        &lt;/button&gt;
      &lt;/form&gt;

      &lt;div&gt;
        {results.map((result) =&gt; (
          &lt;div key={result.place_id} onClick={() =&gt; selectLocation(result)}&gt;
            {result.display_name}
          &lt;/div&gt;
        ))}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>Key concepts here:</p>
<ul>
<li><p><code>useState</code> stores the current input (<code>query</code>) and the array of search <code>results</code>.</p>
</li>
<li><p><code>e.preventDefault()</code> stops the form submission from reloading the page.</p>
</li>
<li><p>Calling <code>searchAddress(query)</code> fetches geocoding results from Nominatim.</p>
</li>
<li><p><code>parseFloat()</code> converts the returned <code>lat</code>/<code>lon</code> strings into JavaScript numbers before using them.</p>
</li>
<li><p><code>onLocationSelect</code> is a callback prop that sends the selected coordinates and name back to the parent component (for example to update the map).</p>
</li>
</ul>
<h2 id="heading-advanced-features">Advanced Features</h2>
<p>We can further extend the map app by adding more advanced functionality. For example:</p>
<h3 id="heading-routing-osrm-graphhopper">Routing (OSRM, GraphHopper)</h3>
<p>You can integrate turn-by-turn routing on your map. A common solution is to use a library like <a href="https://www.liedman.net/leaflet-routing-machine/">Leaflet Routing Machine</a>, which supports OSRM out of the box and has plugins for GraphHopper. This adds a route UI control where users enter start and end points, and the library fetches a route from one of these engines to draw on the map.</p>
<h3 id="heading-custom-tile-providers-carto-maptiler-and-so-on"><strong>Custom Tile Providers (Carto, MapTiler, and so on)</strong></h3>
<p>Instead of the standard <a href="http://tile.openstreetmap.org"><code>tile.openstreetmap.org</code></a>, you can use hosted tile services that offer OSM-based maps. For example, Carto and MapTiler both provide tile APIs (often with custom style options and higher usage limits).</p>
<p>Carto, MapTiler, and similar services are listed among the providers that allow free usage of OSM tiles. By using a custom tile provider, you gain flexibility in map design and avoid hitting the public server’s limits.</p>
<h3 id="heading-vector-maps-maplibre-gl-js">Vector Maps (MapLibre GL JS)</h3>
<p>You can switch from raster tiles to vector tiles for even richer interactivity. Vector tiles send raw map data (geometries and attributes) to the client, which are then rendered in the browser. This allows dynamic styling and advanced features: for instance, you can change the map’s theme on the fly (for example, switch to a “dark mode” style at night) or highlight certain features like bike lanes more prominently.</p>
<p>Libraries like MapLibre GL JS (the open-source successor to Mapbox GL) can display OSM vector tiles with highly customizable styles and smooth zooming/rotation. This makes your map more responsive and adaptable to different use cases.</p>
<h2 id="heading-when-to-choose-openstreetmap-vs-google-maps">When to Choose OpenStreetMap vs Google Maps</h2>
<h3 id="heading-choose-openstreetmap-when">Choose OpenStreetMap when:</h3>
<ul>
<li><p>You need flexibility</p>
</li>
<li><p>You want to reduce costs at scale</p>
</li>
<li><p>You want control over data</p>
</li>
</ul>
<h3 id="heading-choose-google-maps-when">Choose Google Maps when:</h3>
<ul>
<li><p>You want an all-in-one solution</p>
</li>
<li><p>You need features like Street View</p>
</li>
<li><p>You want minimal setup</p>
</li>
</ul>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>OpenStreetMap offers a powerful alternative to Google Maps for developers who need cost control, data ownership, and customization. While it requires understanding different components, the flexibility it provides is worth the learning curve.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Full-Stack CRUD App with React, AWS Lambda, DynamoDB, and Cognito Auth ]]>
                </title>
                <description>
                    <![CDATA[ Building a web application that works only on your local machine is one thing. Building one that is secure, connected to a real database, and accessible to anyone on the internet is another challenge  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/full-stack-aws-react-lambda-dynamodb-tutorial/</link>
                <guid isPermaLink="false">69b96f7ec22d3eeb8ac3bf81</guid>
                
                    <category>
                        <![CDATA[ serverless ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ full stack ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Benedicta Onyebuchi ]]>
                </dc:creator>
                <pubDate>Tue, 17 Mar 2026 15:13:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1a996eff-72f5-4f4d-b8da-cf4d646c3224.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Building a web application that works only on your local machine is one thing. Building one that is secure, connected to a real database, and accessible to anyone on the internet is another challenge entirely. And it requires a different set of tools.</p>
<p>Most production web applications share a common set of needs: they store and retrieve data, they expose that data through an API, they require users to authenticate before accessing sensitive operations, and they need to be deployed somewhere reliable and fast.</p>
<p>Meeting all of those needs used to require managing servers, configuring databases, handling authentication infrastructure, and provisioning hosting environments – often as separate, manual processes.</p>
<p>AWS changes that model significantly. With the combination of services you'll use in this tutorial (Lambda, DynamoDB, API Gateway, Cognito, and CloudFront), you can build and deploy a fully functional, secured, globally distributed application without managing a single server.</p>
<p>Each service handles one specific responsibility:</p>
<ul>
<li><p>DynamoDB stores your data</p>
</li>
<li><p>Lambda runs your business logic on demand</p>
</li>
<li><p>API Gateway exposes your functions as a REST API</p>
</li>
<li><p>Cognito manages user authentication</p>
</li>
<li><p>CloudFront delivers your frontend worldwide over HTTPS.</p>
</li>
</ul>
<p>The AWS CDK (Cloud Development Kit) ties all of this together by letting you define every one of those services as TypeScript code. Instead of clicking through the AWS Console to configure each resource manually, you describe your entire infrastructure in a single file and deploy it with one command.</p>
<p>By the end of this tutorial, you will have a fully deployed vendor management dashboard. Users can sign up, log in, and then create, read, and delete vendors, with all data securely stored in AWS DynamoDB and all routes protected by Amazon Cognito authentication.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>In this handbook, you'll build a two-panel web app where authenticated users can:</p>
<ul>
<li><p>Add a new vendor (name, category, contact email)</p>
</li>
<li><p>View all saved vendors in real time</p>
</li>
<li><p>Delete a vendor from the list</p>
</li>
<li><p>Sign in and sign out securely</p>
</li>
</ul>
<p>The frontend is built with Next.js. The backend runs entirely on AWS: DynamoDB stores the data, Lambda functions handle the logic, API Gateway exposes a REST API, Cognito manages authentication, and CloudFront serves the app globally over HTTPS.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-who-this-is-for">Who This Is For</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-part-1-set-up-your-aws-account-and-tools">Part 1: Set Up Your AWS Account and Tools</a></p>
</li>
<li><p><a href="#heading-part-2-set-up-the-project-structure">Part 2: Set Up the Project Structure</a></p>
</li>
<li><p><a href="#heading-part-3-define-the-database-dynamodb">Part 3: Define the Database (DynamoDB)</a></p>
</li>
<li><p><a href="#heading-part-4-write-the-lambda-functions">Part 4: Write the Lambda Functions</a></p>
</li>
<li><p><a href="#heading-part-5-build-the-api-with-api-gateway">Part 5: Build the API with API Gateway</a></p>
</li>
<li><p><a href="#heading-part-6-deploy-the-backend-to-aws">Part 6: Deploy the Backend to AWS</a></p>
</li>
<li><p><a href="#heading-part-7-build-the-react-frontend">Part 7: Build the React Frontend</a></p>
</li>
<li><p><a href="#heading-part-8-add-authentication-with-amazon-cognito">Part 8: Add Authentication with Amazon Cognito</a></p>
</li>
<li><p><a href="#heading-part-9-deploy-the-frontend-with-s3-and-cloudfront">Part 9: Deploy the Frontend with S3 and CloudFront</a></p>
</li>
<li><p><a href="#heading-what-you-built">What You Built</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-who-this-is-for">Who This Is For</h2>
<p>This tutorial is for developers who know basic JavaScript and React but have never used AWS. You don't need any prior backend, cloud, or DevOps experience. I'll explain every AWS concept before we use it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, make sure you have the following installed and available:</p>
<ul>
<li><p><strong>Node.js 18 or higher</strong>: <a href="https://nodejs.org">Download here</a></p>
</li>
<li><p><strong>npm</strong>: Included with Node.js</p>
</li>
<li><p><strong>A code editor</strong>: I recommend VS Code</p>
</li>
<li><p><strong>A terminal</strong>: Any terminal on macOS, Linux, or Windows (WSL recommended on Windows)</p>
</li>
<li><p><strong>An AWS account</strong>: You will create one in Part 1. A credit card is required, but the Free Tier covers everything in this tutorial.</p>
</li>
<li><p><strong>Basic familiarity with React and TypeScript</strong>: You should understand components, <code>useState</code>, and <code>useEffect</code>.</p>
</li>
</ul>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<p>Before writing any code, here's a plain-English description of how the pieces fit together.</p>
<p>When a user clicks "Add Vendor" in the React app:</p>
<ol>
<li><p>The frontend reads the user's JWT auth token from the browser session</p>
</li>
<li><p>It sends a <code>POST</code> request to API Gateway, including the token in the request header</p>
</li>
<li><p>API Gateway checks the token against Cognito. If the token is invalid or missing, it rejects the request with a 401 error immediately</p>
</li>
<li><p>If the token is valid, API Gateway passes the request to the createVendor Lambda function</p>
</li>
<li><p>The Lambda function writes the new vendor to DynamoDB</p>
</li>
<li><p>DynamoDB confirms the write, and the Lambda returns a success response</p>
</li>
<li><p>The frontend re-fetches the vendor list and updates the UI</p>
</li>
</ol>
<p>The same flow applies to reading and deleting vendors, with different Lambda functions and HTTP methods.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/70486bdc-f272-45db-be30-f10752916546.png" alt="Architecture diagram of the Vendors Tracker Application" style="display:block;margin:0 auto" width="1100" height="499" loading="lazy">

<p><strong>How the app is deployed:</strong> Your React app is exported as a static site, uploaded to an S3 bucket, and served globally through CloudFront. Your backend infrastructure (Lambda functions, API Gateway, DynamoDB, Cognito) is defined in TypeScript using AWS CDK and deployed with a single command.</p>
<h2 id="heading-part-1-set-up-your-aws-account-and-tools">Part 1: Set Up Your AWS Account and Tools</h2>
<p>Before writing any application code, you need three things in place: an AWS account, the right tools on your machine, and credentials that let those tools communicate with AWS on your behalf.</p>
<h3 id="heading-11-create-your-aws-account">1.1 Create Your AWS Account</h3>
<p>If you don't have an AWS account:</p>
<ol>
<li><p>Go to <a href="https://aws.amazon.com">https://aws.amazon.com</a></p>
</li>
<li><p>Click <strong>Create an AWS Account</strong></p>
</li>
<li><p>Follow the sign-up prompts and add a payment method</p>
</li>
<li><p>Once registered, log in to the AWS Management Console</p>
</li>
</ol>
<p>AWS has a Free Tier that covers all the services used in this tutorial. You won't be charged for normal use while following along.</p>
<h3 id="heading-12-install-the-aws-cli-and-cdk">1.2 Install the AWS CLI and CDK</h3>
<p>The <strong>AWS CLI</strong> is a command-line tool that lets you interact with AWS from your terminal: checking resources, configuring credentials, and more.</p>
<p>The <strong>AWS CDK (Cloud Development Kit)</strong> is the tool you will use to define your entire backend (database, Lambda functions, API) using TypeScript code. Instead of clicking through the AWS Console to create each resource, you describe what you want in a TypeScript file and CDK builds it for you.</p>
<p>Install both:</p>
<pre><code class="language-shell"># Install AWS CLI (macOS)
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /

# For Linux, see: https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2-linux.html
# For Windows, see: https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2-windows.html

# Install AWS CDK globally
npm install -g aws-cdk
</code></pre>
<p>Verify both are installed:</p>
<pre><code class="language-shell">aws --version
cdk --version
</code></pre>
<p>Both commands should print a version number. If they do, you are ready to move on.</p>
<h3 id="heading-13-configure-your-aws-credentials-iam">1.3 Configure Your AWS Credentials (IAM)</h3>
<p>This step is critical. Your terminal needs a set of credentials – like a username and password – to act on your behalf inside AWS.</p>
<p>Think of your root account (the one you signed up with) as the master key to your entire AWS account. You should never use it for day-to-day development. Instead, you will create a separate IAM user with its own set of keys. If those keys are ever exposed, you can delete them without compromising your root account.</p>
<h4 id="heading-phase-1-create-an-iam-user">Phase 1: Create an IAM User</h4>
<ol>
<li><p>Log in to the AWS Console and search for IAM in the top search bar</p>
</li>
<li><p>In the left sidebar, click Users, then click Create user</p>
</li>
<li><p>Name the user <code>cdk-dev</code>. Leave "Provide user access to the AWS Management Console" unchecked – you only need terminal access, not console access</p>
</li>
<li><p>On the permissions screen, choose Attach policies directly</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/d4699108-c1aa-4dd3-957c-b84292c719a2.png" alt="IAM Console showing the “Attach policies directly” screen with AdministratorAccess checked" style="display:block;margin:0 auto" width="1100" height="441" loading="lazy">

<ol>
<li>Search for <code>AdministratorAccess</code> and check the box next to it</li>
</ol>
<p>Note on permissions: In a production job you would use a more restricted policy. For this tutorial, Administrator access is needed because CDK creates many different types of AWS resources.</p>
<p>6. Click through to the end and click Create user</p>
<h4 id="heading-phase-2-generate-access-keys">Phase 2: Generate Access Keys</h4>
<ol>
<li><p>Click on your newly created <code>cdk-dev</code> user from the Users list</p>
</li>
<li><p>Go to the Security credentials tab</p>
</li>
<li><p>Scroll down to Access keys and click Create access key</p>
</li>
<li><p>Select Command Line Interface (CLI), check the acknowledgment box, and click Next</p>
</li>
<li><p>Click Create access key</p>
</li>
</ol>
<p><strong>Important</strong>: Copy both the Access Key ID and the Secret Access Key right now. You will never be able to see the Secret Access Key again after closing this screen. Save both values in a password manager or secure note.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/d85bb4eb-0ecf-4d92-be92-d75af5a534c6.png" alt="IAM Console showing the Create access key screen with the Access Key ID and Secret Access Key" style="display:block;margin:0 auto" width="1100" height="426" loading="lazy">

<h4 id="heading-phase-3-connect-your-terminal-to-aws">Phase 3: Connect Your Terminal to AWS</h4>
<p>Run the following command in your terminal:</p>
<pre><code class="language-shell">aws configure
</code></pre>
<p>You will be prompted for four values:</p>
<pre><code class="language-shell">AWS Access Key ID:     [paste your Access Key ID]
AWS Secret Access Key: [paste your Secret Access Key]
Default region name:   us-east-1
Default output format: json
</code></pre>
<p>Use <code>us-east-1</code> as your region for this tutorial. After this step, every CDK and AWS CLI command you run will use these credentials automatically.</p>
<h2 id="heading-part-2-set-up-the-project-structure">Part 2: Set Up the Project Structure</h2>
<p>You will use a <strong>monorepo</strong> layout – one top-level folder with two sub-projects inside: <code>frontend</code> for your React app and <code>backend</code> for your AWS infrastructure code. They are deployed independently but live side by side.</p>
<h3 id="heading-21-create-the-workspace">2.1 Create the Workspace</h3>
<pre><code class="language-shell">mkdir vendor-tracker &amp;&amp; cd vendor-tracker
mkdir backend frontend
</code></pre>
<h3 id="heading-22-initialize-the-frontend-nextjs">2.2 Initialize the Frontend (Next.js)</h3>
<p>Navigate into the <code>frontend</code> folder and run:</p>
<pre><code class="language-shell">cd frontend
npx create-next-app@latest .
</code></pre>
<p>When prompted, choose the following options:</p>
<ul>
<li><p><strong>TypeScript</strong> --&gt; Yes</p>
</li>
<li><p><strong>ESLint</strong> --&gt; Yes</p>
</li>
<li><p><strong>Tailwind CSS</strong> --&gt; Yes</p>
</li>
<li><p><strong>src/ directory</strong> --&gt;No</p>
</li>
<li><p><strong>App Router</strong> --&gt; Yes</p>
</li>
<li><p><strong>Import alias</strong> --&gt; No</p>
</li>
</ul>
<h3 id="heading-23-initialize-the-backend-cdk">2.3 Initialize the Backend (CDK)</h3>
<p>Navigate into the <code>backend</code> folder and run:</p>
<pre><code class="language-shell">cd ../backend
cdk init app --language typescript
</code></pre>
<p>This generates a boilerplate CDK project. The most important file it creates is <code>backend/lib/backend-stack.ts</code>. This is where you will define all of your AWS infrastructure as TypeScript code.</p>
<p>Also install <code>esbuild</code>, which CDK uses to bundle your Lambda functions:</p>
<pre><code class="language-shell">npm install --save-dev esbuild
</code></pre>
<h3 id="heading-24-understanding-cdk-before-you-write-any-code">2.4 Understanding CDK Before You Write Any Code</h3>
<p>CDK is likely different from most tools you have used. Here is how it works:</p>
<p>Normally, you would create AWS resources by clicking through the AWS Console: create a table here, configure a Lambda function there. CDK lets you do all of that using TypeScript code instead.</p>
<p>When you run <code>cdk deploy</code>, CDK reads your TypeScript file, converts it into an AWS CloudFormation template (an internal AWS format for describing infrastructure), and submits it to AWS. AWS then creates all the resources you described.</p>
<p>A few terms you will see throughout this tutorial:</p>
<ul>
<li><p><strong>Stack</strong>: The collection of all AWS resources you define together. Your <code>BackendStack</code> class is your stack.</p>
</li>
<li><p><strong>Construct</strong>: Each individual AWS resource you create inside a stack (a table, a Lambda function, an API) is called a construct.</p>
</li>
<li><p><strong>Deploy</strong>: Running <code>cdk deploy</code> sends your TypeScript definition to AWS and creates or updates the real resources.</p>
</li>
</ul>
<p>The main file you'll work in is <code>backend/lib/backend-stack.ts</code>. Think of it as the blueprint for your entire backend.</p>
<p>Your final project structure will look like this:</p>
<pre><code class="language-plaintext">vendor-tracker/
├── backend/
│   ├── lambda/
│   │   ├── createVendor.ts
│   │   ├── getVendors.ts
│   │   └── deleteVendor.ts
│   ├── lib/
│   │   └── backend-stack.ts
│   └── package.json
└── frontend/
    ├── app/
    │   ├── layout.tsx
    │   ├── page.tsx
    │   └── providers.tsx
    ├── lib/
    │   └── api.ts
    ├── types/
    │   └── vendor.ts
    └── .env.local
</code></pre>
<h2 id="heading-part-3-define-the-database-dynamodb">Part 3: Define the Database (DynamoDB)</h2>
<p>DynamoDB is AWS's NoSQL database. Think of it as a fast, scalable key-value store in the cloud. Every item in a DynamoDB table must have a unique ID called the <strong>partition key</strong>. For your vendor table, that key will be <code>vendorId</code>.</p>
<p>Open <code>backend/lib/backend-stack.ts</code>. Replace the entire file contents with the following:</p>
<pre><code class="language-typescript">import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';

export class BackendStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 1. DynamoDB Table
    const vendorTable = new dynamodb.Table(this, 'VendorTable', {
      partitionKey: {
        name: 'vendorId',
        type: dynamodb.AttributeType.STRING,
      },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY, // For development only
    });
  }
}
</code></pre>
<p><strong>What each line does:</strong></p>
<ul>
<li><p><code>partitionKey</code> tells DynamoDB that <code>vendorId</code> is the unique identifier for every record. No two vendors can share the same <code>vendorId</code>.</p>
</li>
<li><p><code>PAY_PER_REQUEST</code> means you only pay when data is actually read or written. There is no charge when the table is idle, which makes it cost-effective for learning.</p>
</li>
<li><p><code>RemovalPolicy.DESTROY</code> means the table will be deleted when you run <code>cdk destroy</code>. For production apps you would not use this.</p>
</li>
</ul>
<h2 id="heading-part-4-write-the-lambda-functions">Part 4: Write the Lambda Functions</h2>
<p>A Lambda function is your server, but unlike a traditional server, it only runs when it's called. AWS spins it up on demand, runs your code, and shuts it down. You're only charged for the time your code is actually running.</p>
<p>You'll write three Lambda functions:</p>
<ul>
<li><p><code>createVendor.ts</code>: Adds a new vendor to DynamoDB</p>
</li>
<li><p><code>getVendors.ts</code>: Returns all vendors from DynamoDB</p>
</li>
<li><p><code>deleteVendor.ts</code>: Removes a vendor from DynamoDB by ID</p>
</li>
</ul>
<p>Create a new folder inside <code>backend</code>:</p>
<pre><code class="language-shell">mkdir backend/lambda
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/6330a84b-77c3-4001-9783-5fedc89ae1c0.png" alt="6330a84b-77c3-4001-9783-5fedc89ae1c0" style="display:block;margin:0 auto" width="300" height="185" loading="lazy">

<h3 id="heading-a-note-on-the-aws-sdk">A Note on the AWS SDK</h3>
<p>All three Lambda functions use <strong>AWS SDK v3</strong> (<code>@aws-sdk/client-dynamodb</code> and <code>@aws-sdk/lib-dynamodb</code>). This is the current standard. An older version of the SDK (<code>aws-sdk</code>) exists but is deprecated and not bundled in the Node.js 18 Lambda runtime, which is what you'll use. Stick to v3 throughout.</p>
<h3 id="heading-41-create-vendor-lambda">4.1 Create Vendor Lambda</h3>
<p>Create <code>backend/lambda/createVendor.ts</code>:</p>
<pre><code class="language-typescript">import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "crypto";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

export const handler = async (event: any) =&gt; {
  try {
    const body = JSON.parse(event.body);

    const item = {
      vendorId: randomUUID(), // Generates a collision-safe unique ID
      name: body.name,
      category: body.category,
      contactEmail: body.contactEmail,
      createdAt: new Date().toISOString(),
    };

    await docClient.send(
      new PutCommand({
        TableName: process.env.TABLE_NAME!,
        Item: item,
      })
    );

    return {
      statusCode: 201,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "Content-Type,Authorization",
        "Access-Control-Allow-Methods": "OPTIONS,POST,GET,DELETE",
      },
      body: JSON.stringify({ message: "Vendor created", vendorId: item.vendorId }),
    };
  } catch (error) {
    console.error("Error creating vendor:", error);
    return {
      statusCode: 500,
      headers: { "Access-Control-Allow-Origin": "*" },
      body: JSON.stringify({ error: "Failed to create vendor" }),
    };
  }
};
</code></pre>
<p><strong>What each part does:</strong></p>
<ul>
<li><p><code>randomUUID()</code> generates a universally unique ID using Node's built-in <code>crypto</code> module. No extra package is needed. This is more reliable than <code>Date.now()</code>, which can produce duplicate IDs if two requests arrive within the same millisecond.</p>
</li>
<li><p><code>process.env.TABLE_NAME</code> reads the DynamoDB table name from an environment variable. You'll set this value in the CDK stack. This avoids hardcoding the table name inside your Lambda code.</p>
</li>
<li><p>The <code>headers</code> block is required for CORS (Cross-Origin Resource Sharing). Without <code>Access-Control-Allow-Origin</code>, your browser will block responses from a different domain than your frontend. Without <code>Access-Control-Allow-Headers</code>, the <code>Authorization</code> header you add later for Cognito will be rejected during the browser's preflight check.</p>
</li>
</ul>
<h3 id="heading-42-get-vendors-lambda">4.2 Get Vendors Lambda</h3>
<p>Create <code>backend/lambda/getVendors.ts</code>:</p>
<pre><code class="language-typescript">import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, ScanCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

export const handler = async () =&gt; {
  try {
    const response = await docClient.send(
      new ScanCommand({
        TableName: process.env.TABLE_NAME!,
      })
    );

    return {
      statusCode: 200,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "Content-Type,Authorization",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(response.Items ?? []),
    };
  } catch (error) {
    console.error("Error fetching vendors:", error);
    return {
      statusCode: 500,
      headers: { "Access-Control-Allow-Origin": "*" },
      body: JSON.stringify({ error: "Failed to fetch vendors" }),
    };
  }
};
</code></pre>
<p><strong>What each part does:</strong></p>
<ul>
<li><p><code>ScanCommand</code> reads every item in the table and returns them as an array. For a learning project this is fine. In a production app with millions of rows, you would use a more targeted <code>QueryCommand</code> to avoid reading the entire table on every request.</p>
</li>
<li><p><code>response.Items ?? []</code> returns an empty array if the table is empty, preventing the frontend from crashing when there are no vendors yet.</p>
</li>
</ul>
<h3 id="heading-43-delete-vendor-lambda">4.3 Delete Vendor Lambda</h3>
<p>Create <code>backend/lambda/deleteVendor.ts</code>:</p>
<pre><code class="language-typescript">import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, DeleteCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

export const handler = async (event: any) =&gt; {
  try {
    const body = JSON.parse(event.body);
    const { vendorId } = body;

    if (!vendorId) {
      return {
        statusCode: 400,
        headers: { "Access-Control-Allow-Origin": "*" },
        body: JSON.stringify({ error: "vendorId is required" }),
      };
    }

    await docClient.send(
      new DeleteCommand({
        TableName: process.env.TABLE_NAME!,
        Key: { vendorId },
      })
    );

    return {
      statusCode: 200,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "Content-Type,Authorization",
        "Access-Control-Allow-Methods": "OPTIONS,POST,GET,DELETE",
      },
      body: JSON.stringify({ message: "Vendor deleted" }),
    };
  } catch (error) {
    console.error("Error deleting vendor:", error);
    return {
      statusCode: 500,
      headers: { "Access-Control-Allow-Origin": "*" },
      body: JSON.stringify({ error: "Failed to delete vendor" }),
    };
  }
};
</code></pre>
<p><strong>What each part does:</strong></p>
<ul>
<li><p><code>DeleteCommand</code> removes the item whose <code>vendorId</code> matches the key you provide. DynamoDB doesn't return an error if the item doesn't exist. It simply does nothing.</p>
</li>
<li><p>The <code>400</code> guard at the top returns a clear error if the caller forgets to send a <code>vendorId</code>, rather than letting DynamoDB throw a confusing internal error.</p>
</li>
</ul>
<h2 id="heading-part-5-build-the-api-with-api-gateway">Part 5: Build the API with API Gateway</h2>
<p>API Gateway is what gives your Lambda functions a public URL. Without it, there's no way for your browser to trigger a Lambda function. Think of it as the front door of your backend: it receives HTTP requests, checks whether the caller is authorized, routes the request to the correct Lambda, and returns the Lambda's response to the caller.</p>
<p>Now you'll wire everything together in <code>backend/lib/backend-stack.ts</code>.</p>
<h3 id="heading-51-add-lambda-functions-and-api-gateway-to-the-stack">5.1 Add Lambda Functions and API Gateway to the Stack</h3>
<p>Replace the entire contents of <code>backend/lib/backend-stack.ts</code> with this complete, assembled file:</p>
<pre><code class="language-typescript">import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';

export class BackendStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 1. DynamoDB Table 
    const vendorTable = new dynamodb.Table(this, 'VendorTable', {
      partitionKey: {
        name: 'vendorId',
        type: dynamodb.AttributeType.STRING,
      },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // 2. Lambda Functions
    const lambdaEnv = { TABLE_NAME: vendorTable.tableName };

    const createVendorLambda = new NodejsFunction(this, 'CreateVendorHandler', {
      entry: 'lambda/createVendor.ts',
      handler: 'handler',
      environment: lambdaEnv,
    });

    const getVendorsLambda = new NodejsFunction(this, 'GetVendorsHandler', {
      entry: 'lambda/getVendors.ts',
      handler: 'handler',
      environment: lambdaEnv,
    });

    const deleteVendorLambda = new NodejsFunction(this, 'DeleteVendorHandler', {
      entry: 'lambda/deleteVendor.ts',
      handler: 'handler',
      environment: lambdaEnv,
    });

    // 3. Permissions (Least Privilege)
    vendorTable.grantWriteData(createVendorLambda);
    vendorTable.grantReadData(getVendorsLambda);
    vendorTable.grantWriteData(deleteVendorLambda);

    // 4. API Gateway
    const api = new apigateway.RestApi(this, 'VendorApi', {
      restApiName: 'Vendor Service',
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: apigateway.Cors.ALL_METHODS,
        allowHeaders: ['Content-Type', 'Authorization'],
      },
    });

    const vendors = api.root.addResource('vendors');
    vendors.addMethod('POST', new apigateway.LambdaIntegration(createVendorLambda));
    vendors.addMethod('GET', new apigateway.LambdaIntegration(getVendorsLambda));
    vendors.addMethod('DELETE', new apigateway.LambdaIntegration(deleteVendorLambda));

    // 5. Outputs
    new cdk.CfnOutput(this, 'ApiEndpoint', {
      value: api.url,
    });
  }
}
</code></pre>
<p><strong>What each section does:</strong></p>
<p><code>NodejsFunction</code> is a special CDK construct that automatically bundles your Lambda code and all its dependencies into a single file using <code>esbuild</code> before uploading it to AWS. This is why you installed <code>esbuild</code> in Part 2.</p>
<p>Always use <code>NodejsFunction</code> instead of the basic <code>lambda.Function</code> construct. The basic version requires you to manually manage bundling, which causes "Module not found" errors at runtime.</p>
<p><strong>Permissions (Least Privilege):</strong> In AWS, no resource can communicate with any other resource by default. A Lambda function has no access to DynamoDB, S3, or anything else unless you explicitly grant it.</p>
<p>This is called the <strong>Least Privilege</strong> principle: each piece of your system gets exactly the permissions it needs, and nothing more. <code>grantWriteData</code> lets a Lambda write and delete items. <code>grantReadData</code> lets a Lambda read items. Using separate grants for each function means the <code>getVendors</code> Lambda can never accidentally delete data.</p>
<p><code>CfnOutput</code> prints a value to your terminal after <code>cdk deploy</code> completes. You'll use the <code>ApiEndpoint</code> URL to configure your frontend.</p>
<h2 id="heading-part-6-deploy-the-backend-to-aws">Part 6: Deploy the Backend to AWS</h2>
<p>Your infrastructure is fully defined in code. Now you'll deploy it to AWS and get a live API URL.</p>
<h3 id="heading-61-bootstrap-your-aws-environment">6.1 Bootstrap Your AWS Environment</h3>
<p>Before your first CDK deployment, AWS needs a small landing zone in your account – an S3 bucket where CDK can upload your Lambda bundles and other assets. This setup step is called <strong>bootstrapping</strong> and only needs to be done once per AWS account per region.</p>
<p>From inside your <code>backend</code> folder, run:</p>
<pre><code class="language-shell">cdk bootstrap
</code></pre>
<p><strong>Important</strong>: Bootstrapping is region-specific. If you ever switch to a different AWS region, you will need to run <code>cdk bootstrap</code> again in that region.</p>
<h3 id="heading-62-deploy">6.2 Deploy</h3>
<p>Run:</p>
<pre><code class="language-shell">cdk deploy
</code></pre>
<p>CDK will display a summary of everything it is about to create and ask for your confirmation. Type <code>y</code> and press Enter.</p>
<p>When the deployment finishes, you'll see an <strong>Outputs</strong> section in your terminal:</p>
<pre><code class="language-plaintext">Outputs:
BackendStack.ApiEndpoint = https://abcdef123.execute-api.us-east-1.amazonaws.com/prod/
</code></pre>
<p>Copy that URL. You'll need it when building the frontend.</p>
<h3 id="heading-63-troubleshooting-how-to-read-aws-error-logs">6.3 Troubleshooting: How to Read AWS Error Logs</h3>
<p>Real deployments rarely go perfectly the first time. If something goes wrong after deploying, here is how to find the actual error message.</p>
<h4 id="heading-error-502-bad-gateway">Error: 502 Bad Gateway</h4>
<p>A <code>502</code> means API Gateway received your request but your Lambda crashed before it could respond. The most common cause is a missing environment variable – for example, if <code>TABLE_NAME</code> was not passed correctly and the Lambda cannot find the table.</p>
<p>To find the actual error message, use <strong>CloudWatch Logs</strong>:</p>
<ol>
<li><p>Log in to the AWS Console and search for CloudWatch</p>
</li>
<li><p>In the left sidebar, click Logs --&gt; Log groups</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/abfb78fc-574b-4a75-a12b-12fb09f041b3.png" alt="CloudWatch left sidebar with log groups, and the search field showing /aws/lambda/" style="display:block;margin:0 auto" width="1915" height="428" loading="lazy">

<ol>
<li><p>Find the group named <code>/aws/lambda/BackendStack-CreateVendorHandler...</code></p>
</li>
<li><p>Click the most recent Log stream</p>
</li>
<li><p>Read the error message. It will tell you exactly what went wrong</p>
</li>
</ol>
<p>Two common messages and their fixes:</p>
<ul>
<li><p><code>Runtime.ImportModuleError</code> : Your Lambda cannot find a module. Make sure you're using <code>NodejsFunction</code> (not <code>lambda.Function</code>) in your CDK stack. <code>NodejsFunction</code> automatically bundles dependencies; <code>lambda.Function</code> does not.</p>
</li>
<li><p><code>AccessDeniedException</code>: Your Lambda tried to access DynamoDB but doesn't have permission. Check that you have the correct <code>grantWriteData</code> or <code>grantReadData</code> call in your stack for that Lambda.</p>
</li>
</ul>
<h2 id="heading-part-7-build-the-react-frontend">Part 7: Build the React Frontend</h2>
<p>Your backend is live. Now you'll build the React UI that talks to it.</p>
<h3 id="heading-71-define-the-vendor-type">7.1 Define the Vendor Type</h3>
<p>Before writing any API or component code, define what a "vendor" looks like in TypeScript. This gives you type safety throughout your frontend code.</p>
<p>Create <code>frontend/types/vendor.ts</code>:</p>
<pre><code class="language-typescript">export interface Vendor {
  vendorId?: string; // Optional when creating — the Lambda generates it
  name: string;
  category: string;
  contactEmail: string;
  createdAt?: string;
}
</code></pre>
<p>The <code>vendorId?</code> is marked optional with <code>?</code> because when you are <em>creating</em> a new vendor, you don't have an ID yet. The <code>createVendor</code> Lambda generates one. When you <em>read</em> vendors back from the API, <code>vendorId</code> will always be present.</p>
<h3 id="heading-72-create-the-api-service-layer">7.2 Create the API Service Layer</h3>
<p>Rather than writing <code>fetch</code> calls directly inside your React components, you'll centralize all your API logic in one file. This pattern is called a <strong>service layer</strong>. It keeps your components clean and makes it easy to update API calls in one place.</p>
<p>First, create a <code>.env.local</code> file inside your <code>frontend</code> folder to store your API URL:</p>
<pre><code class="language-bash"># frontend/.env.local
NEXT_PUBLIC_API_URL=https://abcdef123.execute-api.us-east-1.amazonaws.com/prod
</code></pre>
<p>Replace the URL with the <code>ApiEndpoint</code> value from your <code>cdk deploy</code> output. The <code>NEXT_PUBLIC_</code> prefix is required by Next.js to make an environment variable accessible in the browser.</p>
<p>You might be wondering: <strong>why not hardcode the URL</strong>? If you paste your API URL directly into your code and push it to GitHub, it becomes publicly visible. While an API URL alone does not expose your data (Cognito will protect that), it's good practice to keep URLs and secrets out of source control. Always use .env.local and add it to your .gitignore.</p>
<p>Make sure <code>.env.local</code> is in your <code>.gitignore</code>:</p>
<pre><code class="language-shell">echo ".env.local" &gt;&gt; frontend/.gitignore
</code></pre>
<p>Now create <code>frontend/lib/api.ts</code>:</p>
<pre><code class="language-typescript">import { Vendor } from '@/types/vendor';

const BASE_URL = process.env.NEXT_PUBLIC_API_URL!;

export const getVendors = async (): Promise&lt;Vendor[]&gt; =&gt; {
  const response = await fetch(`${BASE_URL}/vendors`);
  if (!response.ok) throw new Error('Failed to fetch vendors');
  return response.json();
};

export const createVendor = async (vendor: Omit&lt;Vendor, 'vendorId' | 'createdAt'&gt;): Promise&lt;void&gt; =&gt; {
  const response = await fetch(`${BASE_URL}/vendors`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(vendor),
  });
  if (!response.ok) throw new Error('Failed to create vendor');
};

export const deleteVendor = async (vendorId: string): Promise&lt;void&gt; =&gt; {
  const response = await fetch(`${BASE_URL}/vendors`, {
    method: 'DELETE',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ vendorId }),
  });
  if (!response.ok) throw new Error('Failed to delete vendor');
};
</code></pre>
<p><strong>What each part does:</strong></p>
<ul>
<li><p><code>Omit&lt;Vendor, 'vendorId' | 'createdAt'&gt;</code> means the <code>createVendor</code> function accepts a vendor without an ID or timestamp (those are generated server-side).</p>
</li>
<li><p><code>if (!response.ok) throw new Error(...)</code> ensures that any HTTP error (4xx or 5xx) surfaces as a JavaScript error in your component, where you can show the user a meaningful message instead of silently failing.</p>
</li>
</ul>
<p>You'll update these functions later in Part 8 to include the Cognito auth token.</p>
<h3 id="heading-73-build-the-main-page">7.3 Build the Main Page</h3>
<p>Now create the main page component. It includes a form for adding vendors and a live list that displays all current vendors.</p>
<p>Replace the contents of <code>frontend/app/page.tsx</code> with:</p>
<pre><code class="language-typescript">'use client';

import { useState, useEffect } from 'react';
import { createVendor, getVendors, deleteVendor } from '@/lib/api';
import { Vendor } from '@/types/vendor';

export default function Home() {
  const [vendors, setVendors] = useState&lt;Vendor[]&gt;([]);
  const [form, setForm] = useState({ name: '', category: '', contactEmail: '' });
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const loadVendors = async () =&gt; {
    try {
      const data = await getVendors();
      setVendors(data);
    } catch {
      setError('Failed to load vendors.');
    }
  };

  // Load vendors once when the page first renders
  useEffect(() =&gt; {
    loadVendors();
  }, []);
  // The empty [] means this runs only once. Without it, the effect would
  // run after every render, causing an infinite loop of fetch requests.

  const handleSubmit = async (e: React.FormEvent) =&gt; {
    e.preventDefault(); // Prevent the browser from reloading the page on submit
    setLoading(true);
    setError('');
    try {
      await createVendor(form);
      setForm({ name: '', category: '', contactEmail: '' }); // Reset the form
      await loadVendors(); // Refresh the list from DynamoDB
    } catch {
      setError('Failed to add vendor. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  const handleDelete = async (vendorId: string) =&gt; {
    try {
      await deleteVendor(vendorId);
      await loadVendors(); // Refresh after deleting
    } catch {
      setError('Failed to delete vendor.');
    }
  };

  return (
    &lt;main className="p-10 max-w-5xl mx-auto"&gt;
      &lt;h1 className="text-3xl font-bold mb-2 text-gray-900"&gt;Vendor Tracker&lt;/h1&gt;
      &lt;p className="text-gray-500 mb-8"&gt;Manage your vendors, stored in AWS DynamoDB.&lt;/p&gt;

      {error &amp;&amp; (
        &lt;div className="mb-4 p-3 bg-red-100 text-red-700 rounded"&gt;{error}&lt;/div&gt;
      )}

      &lt;div className="grid grid-cols-1 md:grid-cols-2 gap-10"&gt;

        {/* ── Add Vendor Form ── */}
        &lt;section&gt;
          &lt;h2 className="text-xl font-semibold mb-4 text-gray-800"&gt;Add New Vendor&lt;/h2&gt;
          &lt;form onSubmit={handleSubmit} className="space-y-4"&gt;
            &lt;input
              className="w-full p-2 border rounded text-black focus:outline-none focus:ring-2 focus:ring-orange-400"
              placeholder="Vendor Name"
              value={form.name}
              onChange={e =&gt; setForm({ ...form, name: e.target.value })}
              required
            /&gt;
            &lt;input
              className="w-full p-2 border rounded text-black focus:outline-none focus:ring-2 focus:ring-orange-400"
              placeholder="Category (e.g. SaaS, Hardware)"
              value={form.category}
              onChange={e =&gt; setForm({ ...form, category: e.target.value })}
              required
            /&gt;
            &lt;input
              className="w-full p-2 border rounded text-black focus:outline-none focus:ring-2 focus:ring-orange-400"
              placeholder="Contact Email"
              type="email"
              value={form.contactEmail}
              onChange={e =&gt; setForm({ ...form, contactEmail: e.target.value })}
              required
            /&gt;
            &lt;button
              type="submit"
              disabled={loading}
              className="w-full bg-orange-500 text-white p-2 rounded hover:bg-orange-600 disabled:bg-gray-400 transition-colors"
            &gt;
              {loading ? 'Saving...' : 'Add Vendor'}
            &lt;/button&gt;
          &lt;/form&gt;
        &lt;/section&gt;

        {/* ── Vendor List ── */}
        &lt;section&gt;
          &lt;h2 className="text-xl font-semibold mb-4 text-gray-800"&gt;
            Current Vendors ({vendors.length})
          &lt;/h2&gt;
          &lt;div className="space-y-3"&gt;
            {vendors.length === 0 ? (
              &lt;p className="text-gray-400 italic"&gt;No vendors yet. Add one using the form.&lt;/p&gt;
            ) : (
              vendors.map(v =&gt; (
                &lt;div
                  key={v.vendorId}
                  className="p-4 border rounded shadow-sm bg-white flex justify-between items-start"
                &gt;
                  &lt;div&gt;
                    &lt;p className="font-semibold text-gray-900"&gt;{v.name}&lt;/p&gt;
                    &lt;p className="text-sm text-gray-500"&gt;{v.category} · {v.contactEmail}&lt;/p&gt;
                  &lt;/div&gt;
                  &lt;button
                    onClick={() =&gt; v.vendorId &amp;&amp; handleDelete(v.vendorId)}
                    className="ml-4 text-sm text-red-500 hover:text-red-700 hover:underline"
                  &gt;
                    Delete
                  &lt;/button&gt;
                &lt;/div&gt;
              ))
            )}
          &lt;/div&gt;
        &lt;/section&gt;

      &lt;/div&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p><strong>Key points in this component:</strong></p>
<ul>
<li><p><code>'use client'</code> at the top is a Next.js directive. It tells Next.js that this component uses browser APIs (<code>useState</code>, <code>useEffect</code>, event handlers) and must run in the browser, not be pre-rendered on the server.</p>
</li>
<li><p><code>e.preventDefault()</code> inside <code>handleSubmit</code> stops the browser's default form submission behavior, which would cause a full page reload and wipe your React state.</p>
</li>
<li><p>After every <code>createVendor</code> or <code>deleteVendor</code> call, <code>loadVendors()</code> is called again. This re-fetches the latest data from DynamoDB so the UI always matches what is actually stored in the database.</p>
</li>
</ul>
<h3 id="heading-74-test-the-app-locally">7.4 Test the App Locally</h3>
<p>Start your Next.js development server:</p>
<pre><code class="language-shell">cd frontend
npm run dev
</code></pre>
<p>Open <code>http://localhost:3000</code> in your browser. You should see the two-panel layout. Try adding a vendor and confirm it appears in the list.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/281f971a-27b8-49b3-9079-e12601525d80.png" alt="The running Vendor Tracker app at localhost:3000 showing the two-panel layout with the Add Vendor form on the left and an empty vendor list on the right" style="display:block;margin:0 auto" width="1690" height="708" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/88b5dd74-5847-4310-bec3-b1a2b129fbaa.png" alt="The Vendor Tracker app after a vendor has been added, showing the vendor card in the list" style="display:block;margin:0 auto" width="1646" height="598" loading="lazy">

<h4 id="heading-verifying-the-connection-to-aws">Verifying the connection to AWS:</h4>
<p>Open Chrome DevTools (F12) and click the Network tab. When you add a vendor, you should see:</p>
<ul>
<li><p>A <code>POST</code> request to your AWS API URL returning a <strong>201</strong> status code</p>
</li>
<li><p>A <code>GET</code> request returning <strong>200</strong> with the updated vendor list</p>
</li>
</ul>
<p>You can also verify the data was saved by opening the AWS Console, navigating to <strong>DynamoDB --&gt; Tables --&gt; VendorTable --&gt; Explore table items</strong>. Your vendor should appear there.</p>
<h2 id="heading-part-8-add-authentication-with-amazon-cognito">Part 8: Add Authentication with Amazon Cognito</h2>
<p>Right now your API is completely open. Anyone who finds your API URL can add or delete vendors. You'll fix that with <strong>Amazon Cognito</strong>.</p>
<p>Cognito is AWS's authentication service. It manages a User Pool – a database of registered users with usernames and passwords. When a user logs in, Cognito issues a JWT (JSON Web Token): a cryptographically signed string that proves who the user is. Your API Gateway will check for this token on every request. No valid token means no access.</p>
<p><strong>What is a JWT?</strong> A JSON Web Token is a string that looks like <code>eyJhbGci...</code>. It contains encoded information about the user and is signed by Cognito using a secret key.</p>
<p>API Gateway can verify the signature without contacting Cognito on every request, which makes token checking fast. Think of it as a tamper-proof badge: anyone can read the name on it, but only Cognito's signature makes it valid.</p>
<h3 id="heading-81-add-cognito-to-the-cdk-stack">8.1 Add Cognito to the CDK Stack</h3>
<p>Open <code>backend/lib/backend-stack.ts</code> and update it to include Cognito. Here is the complete updated file:</p>
<pre><code class="language-typescript">import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';

export class BackendStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // ─── 1. DynamoDB Table ────────────────────────────────────────────────────
    const vendorTable = new dynamodb.Table(this, 'VendorTable', {
      partitionKey: { name: 'vendorId', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // ─── 2. Lambda Functions ──────────────────────────────────────────────────
    const lambdaEnv = { TABLE_NAME: vendorTable.tableName };

    const createVendorLambda = new NodejsFunction(this, 'CreateVendorHandler', {
      entry: 'lambda/createVendor.ts',
      handler: 'handler',
      environment: lambdaEnv,
    });

    const getVendorsLambda = new NodejsFunction(this, 'GetVendorsHandler', {
      entry: 'lambda/getVendors.ts',
      handler: 'handler',
      environment: lambdaEnv,
    });

    const deleteVendorLambda = new NodejsFunction(this, 'DeleteVendorHandler', {
      entry: 'lambda/deleteVendor.ts',
      handler: 'handler',
      environment: lambdaEnv,
    });

    // ─── 3. Permissions ───────────────────────────────────────────────────────
    vendorTable.grantWriteData(createVendorLambda);
    vendorTable.grantReadData(getVendorsLambda);
    vendorTable.grantWriteData(deleteVendorLambda);

    // ─── 4. Cognito User Pool ─────────────────────────────────────────────────
    const userPool = new cognito.UserPool(this, 'VendorUserPool', {
      selfSignUpEnabled: true,
      signInAliases: { email: true },
      autoVerify: { email: true },
      userVerification: {
        emailStyle: cognito.VerificationEmailStyle.CODE,
      },
    });

    // Required to host Cognito's internal auth endpoints
    userPool.addDomain('VendorUserPoolDomain', {
      cognitoDomain: {
        domainPrefix: `vendor-tracker-${this.account}`,
      },
    });

    const userPoolClient = userPool.addClient('VendorAppClient');

    // ─── 5. API Gateway + Authorizer ──────────────────────────────────────────
    const api = new apigateway.RestApi(this, 'VendorApi', {
      restApiName: 'Vendor Service',
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: apigateway.Cors.ALL_METHODS,
        allowHeaders: ['Content-Type', 'Authorization'],
      },
    });

    const authorizer = new apigateway.CognitoUserPoolsAuthorizer(
      this,
      'VendorAuthorizer',
      { cognitoUserPools: [userPool] }
    );

    const authOptions = {
      authorizer,
      authorizationType: apigateway.AuthorizationType.COGNITO,
    };

    const vendors = api.root.addResource('vendors');
    vendors.addMethod('GET', new apigateway.LambdaIntegration(getVendorsLambda), authOptions);
    vendors.addMethod('POST', new apigateway.LambdaIntegration(createVendorLambda), authOptions);
    vendors.addMethod('DELETE', new apigateway.LambdaIntegration(deleteVendorLambda), authOptions);

    // ─── 6. Outputs ───────────────────────────────────────────────────────────
    new cdk.CfnOutput(this, 'ApiEndpoint', { value: api.url });
    new cdk.CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
    new cdk.CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId });
  }
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/c5e91abf-e6af-429f-bf5b-b14d18233f6c.png" alt="The newly created User Pool (VendorUserPool...) in the User Pools list, with the User Pool ID visible" style="display:block;margin:0 auto" width="1100" height="454" loading="lazy">

<p><strong>What changed:</strong></p>
<ul>
<li><p><code>CognitoUserPoolsAuthorizer</code> tells API Gateway to check every request for a valid Cognito JWT before passing it to any Lambda. If the token is missing or invalid, API Gateway rejects the request with a <code>401 Unauthorized</code> response without ever touching your Lambda.</p>
</li>
<li><p><code>authOptions</code> is applied to all three API methods: GET, POST, and DELETE. All routes are now protected.</p>
</li>
<li><p><code>autoVerify: { email: true }</code> tells Cognito to mark the email attribute as verified after a user confirms via the verification code email. It doesn't skip the verification email, as users still receive a code. If you want to skip verification during development, you can manually confirm users in the Cognito console (covered in section 8.5).</p>
</li>
<li><p>Two new <code>CfnOutput</code> values (<code>UserPoolId</code> and <code>UserPoolClientId</code>) will appear in your terminal after the next deployment. Your frontend needs them to connect to Cognito.</p>
</li>
</ul>
<p>Deploy the updated stack:</p>
<pre><code class="language-shell">cd backend
cdk deploy
</code></pre>
<p>After deployment, your terminal output will include three values:</p>
<pre><code class="language-plaintext">Outputs:
BackendStack.ApiEndpoint     = https://abc123.execute-api.us-east-1.amazonaws.com/prod/
BackendStack.UserPoolId      = us-east-1_xxxxxxxx
BackendStack.UserPoolClientId = xxxxxxxxxxxxxxxxxxxx
</code></pre>
<p>Save all three values. You'll use them in the next step.</p>
<h3 id="heading-82-install-and-configure-aws-amplify">8.2 Install and Configure AWS Amplify</h3>
<p><strong>AWS Amplify</strong> is a frontend library that handles all the complex authentication logic for you: it manages the login UI, stores tokens in the browser, refreshes expired tokens automatically, and exposes a simple API to read the current user's session.</p>
<p>Install the Amplify libraries inside your <code>frontend</code> folder:</p>
<pre><code class="language-shell">cd frontend
npm install aws-amplify @aws-amplify/ui-react
</code></pre>
<p>Create <code>frontend/app/providers.tsx</code>. This file initializes Amplify with your Cognito configuration. It runs once when the app loads:</p>
<pre><code class="language-typescript">'use client';

import { Amplify } from 'aws-amplify';

Amplify.configure(
  {
    Auth: {
      Cognito: {
        userPoolId: process.env.NEXT_PUBLIC_USER_POOL_ID!,
        userPoolClientId: process.env.NEXT_PUBLIC_USER_POOL_CLIENT_ID!,
      },
    },
  },
  { ssr: true }
);

export function Providers({ children }: { children: React.ReactNode }) {
  return &lt;&gt;{children}&lt;/&gt;;
}
</code></pre>
<p>Add the Cognito IDs to your <code>frontend/.env.local</code> file:</p>
<pre><code class="language-shell">NEXT_PUBLIC_API_URL=https://abc123.execute-api.us-east-1.amazonaws.com/prod
NEXT_PUBLIC_USER_POOL_ID=us-east-1_xxxxxxxx
NEXT_PUBLIC_USER_POOL_CLIENT_ID=xxxxxxxxxxxxxxxxxxxx
</code></pre>
<p>Replace the values with the outputs from your <code>cdk deploy</code>.</p>
<h3 id="heading-83-wire-providers-into-the-app-layout">8.3 Wire Providers into the App Layout</h3>
<p><strong>This step is critical.</strong> Amplify must be initialized before any component tries to use authentication. If you skip this step, <code>fetchAuthSession()</code> will throw an "Amplify not configured" error and nothing will work.</p>
<p>Open <code>frontend/app/layout.tsx</code> and update it to wrap the app in the <code>Providers</code> component:</p>
<pre><code class="language-typescript">import type { Metadata } from 'next';
import './globals.css';
import { Providers } from './providers';

export const metadata: Metadata = {
  title: 'Vendor Tracker',
  description: 'Manage your vendors with AWS',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &lt;html lang="en"&gt;
      &lt;body&gt;
        &lt;Providers&gt;{children}&lt;/Providers&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<p>By wrapping <code>{children}</code> in <code>&lt;Providers&gt;</code>, you ensure that Amplify is configured once at the root of the app, before any child page or component renders.</p>
<h3 id="heading-84-protect-the-ui-with-withauthenticator">8.4 Protect the UI with withAuthenticator</h3>
<p>Now wrap your <code>Home</code> component so that unauthenticated users see a login screen instead of the dashboard.</p>
<p>Replace the contents of <code>frontend/app/page.tsx</code> with this updated version:</p>
<pre><code class="language-typescript">'use client';

import { useState, useEffect } from 'react';
import { withAuthenticator } from '@aws-amplify/ui-react';
import '@aws-amplify/ui-react/styles.css';
import { getVendors, createVendor, deleteVendor } from '@/lib/api';
import { Vendor } from '@/types/vendor';

// withAuthenticator injects `signOut` and `user` as props automatically
function Home({ signOut, user }: { signOut?: () =&gt; void; user?: any }) {
  const [vendors, setVendors] = useState&lt;Vendor[]&gt;([]);
  const [form, setForm] = useState({ name: '', category: '', contactEmail: '' });
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  const loadVendors = async () =&gt; {
    try {
      const data = await getVendors();
      setVendors(data);
    } catch {
      setError('Failed to load vendors.');
    }
  };

  useEffect(() =&gt; {
    loadVendors();
  }, []);

  const handleSubmit = async (e: React.FormEvent) =&gt; {
    e.preventDefault();
    setLoading(true);
    setError('');
    try {
      await createVendor(form);
      setForm({ name: '', category: '', contactEmail: '' });
      await loadVendors();
    } catch {
      setError('Failed to add vendor.');
    } finally {
      setLoading(false);
    }
  };

  const handleDelete = async (vendorId: string) =&gt; {
    try {
      await deleteVendor(vendorId);
      await loadVendors();
    } catch {
      setError('Failed to delete vendor.');
    }
  };

  return (
    &lt;main className="p-10 max-w-5xl mx-auto"&gt;
      {/* ── Header ── */}
      &lt;header className="flex justify-between items-center mb-8 p-4 bg-gray-100 rounded"&gt;
        &lt;div&gt;
          &lt;h1 className="text-xl font-bold text-gray-900"&gt;Vendor Tracker&lt;/h1&gt;
          &lt;p className="text-sm text-gray-500"&gt;Signed in as: {user?.signInDetails?.loginId}&lt;/p&gt;
        &lt;/div&gt;
        &lt;button
          onClick={signOut}
          className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition-colors"
        &gt;
          Sign Out
        &lt;/button&gt;
      &lt;/header&gt;

      {error &amp;&amp; (
        &lt;div className="mb-4 p-3 bg-red-100 text-red-700 rounded"&gt;{error}&lt;/div&gt;
      )}

      &lt;div className="grid grid-cols-1 md:grid-cols-2 gap-10"&gt;

        {/* ── Add Vendor Form ── */}
        &lt;section&gt;
          &lt;h2 className="text-xl font-semibold mb-4 text-gray-800"&gt;Add New Vendor&lt;/h2&gt;
          &lt;form onSubmit={handleSubmit} className="space-y-4"&gt;
            &lt;input
              className="w-full p-2 border rounded text-black"
              placeholder="Vendor Name"
              value={form.name}
              onChange={e =&gt; setForm({ ...form, name: e.target.value })}
              required
            /&gt;
            &lt;input
              className="w-full p-2 border rounded text-black"
              placeholder="Category (e.g. SaaS, Hardware)"
              value={form.category}
              onChange={e =&gt; setForm({ ...form, category: e.target.value })}
              required
            /&gt;
            &lt;input
              className="w-full p-2 border rounded text-black"
              placeholder="Contact Email"
              type="email"
              value={form.contactEmail}
              onChange={e =&gt; setForm({ ...form, contactEmail: e.target.value })}
              required
            /&gt;
            &lt;button
              type="submit"
              disabled={loading}
              className="w-full bg-orange-500 text-white p-2 rounded hover:bg-orange-600 disabled:bg-gray-400"
            &gt;
              {loading ? 'Saving...' : 'Add Vendor'}
            &lt;/button&gt;
          &lt;/form&gt;
        &lt;/section&gt;

        {/* ── Vendor List ── */}
        &lt;section&gt;
          &lt;h2 className="text-xl font-semibold mb-4 text-gray-800"&gt;
            Current Vendors ({vendors.length})
          &lt;/h2&gt;
          &lt;div className="space-y-3"&gt;
            {vendors.length === 0 ? (
              &lt;p className="text-gray-400 italic"&gt;No vendors yet.&lt;/p&gt;
            ) : (
              vendors.map(v =&gt; (
                &lt;div
                  key={v.vendorId}
                  className="p-4 border rounded shadow-sm bg-white flex justify-between items-start"
                &gt;
                  &lt;div&gt;
                    &lt;p className="font-semibold text-gray-900"&gt;{v.name}&lt;/p&gt;
                    &lt;p className="text-sm text-gray-500"&gt;{v.category} · {v.contactEmail}&lt;/p&gt;
                  &lt;/div&gt;
                  &lt;button
                    onClick={() =&gt; v.vendorId &amp;&amp; handleDelete(v.vendorId)}
                    className="ml-4 text-sm text-red-500 hover:text-red-700 hover:underline"
                  &gt;
                    Delete
                  &lt;/button&gt;
                &lt;/div&gt;
              ))
            )}
          &lt;/div&gt;
        &lt;/section&gt;

      &lt;/div&gt;
    &lt;/main&gt;
  );
}

// Wrapping Home with withAuthenticator means any user who is not logged in
// will see Amplify's built-in login/signup screen instead of this component.
export default withAuthenticator(Home);
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/e65a88dc-ea75-4daa-b7cf-eac3406c8060.png" alt="Amplify-generated login screen" style="display:block;margin:0 auto" width="1639" height="805" loading="lazy">

<h3 id="heading-85-pass-the-auth-token-to-api-calls">8.5 Pass the Auth Token to API Calls</h3>
<p>Now that API Gateway requires a JWT on every request, your <code>fetch</code> calls need to include the token in the <code>Authorization</code> header. Without it, every request will return a <code>401 Unauthorized</code> error.</p>
<p>Update <code>frontend/lib/api.ts</code> with a token helper and updated fetch calls:</p>
<pre><code class="language-typescript">import { fetchAuthSession } from 'aws-amplify/auth';
import { Vendor } from '@/types/vendor';

const BASE_URL = process.env.NEXT_PUBLIC_API_URL!;

// Retrieves the current user's JWT token from the active Amplify session
const getAuthToken = async (): Promise&lt;string&gt; =&gt; {
  const session = await fetchAuthSession();
  const token = session.tokens?.idToken?.toString();
  if (!token) throw new Error('No active session. Please sign in.');
  return token;
};

export const getVendors = async (): Promise&lt;Vendor[]&gt; =&gt; {
  const token = await getAuthToken();
  const response = await fetch(`${BASE_URL}/vendors`, {
    headers: { Authorization: token },
  });
  if (!response.ok) throw new Error('Failed to fetch vendors');
  return response.json();
};

export const createVendor = async (
  vendor: Omit&lt;Vendor, 'vendorId' | 'createdAt'&gt;
): Promise&lt;void&gt; =&gt; {
  const token = await getAuthToken();
  const response = await fetch(`${BASE_URL}/vendors`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: token,
    },
    body: JSON.stringify(vendor),
  });
  if (!response.ok) throw new Error('Failed to create vendor');
};

export const deleteVendor = async (vendorId: string): Promise&lt;void&gt; =&gt; {
  const token = await getAuthToken();
  const response = await fetch(`${BASE_URL}/vendors`, {
    method: 'DELETE',
    headers: {
      'Content-Type': 'application/json',
      Authorization: token,
    },
    body: JSON.stringify({ vendorId }),
  });
  if (!response.ok) throw new Error('Failed to delete vendor');
};
</code></pre>
<p><strong>What</strong> <code>getAuthToken</code> <strong>does:</strong></p>
<p><code>fetchAuthSession()</code> reads the currently logged-in user's session from the browser. Amplify stores the session in memory and <code>localStorage</code> after the user signs in.</p>
<p><code>session.tokens?.idToken</code> is the JWT string that API Gateway's Cognito Authorizer is looking for. Passing it as the <code>Authorization</code> header tells API Gateway: "This request is from an authenticated user."</p>
<h3 id="heading-86-troubleshooting-cognito">8.6 Troubleshooting Cognito</h3>
<h4 id="heading-unconfirmed-user-error-after-sign-up">"Unconfirmed" user error after sign-up</h4>
<p>When a new user signs up through the Amplify UI, Cognito marks the account as <em>Unconfirmed</em> until the user verifies their email address. A verification code is sent to the user's email. After entering the code, the account becomes confirmed and the user can log in.</p>
<p>If you are testing locally and want to skip the email step, you can manually confirm any account in the AWS Console:</p>
<ol>
<li><p>Open the AWS Console and navigate to Cognito</p>
</li>
<li><p>Click on your User Pool (<code>VendorUserPool...</code>)</p>
</li>
<li><p>Click the Users tab</p>
</li>
<li><p>Click on the user's email address</p>
</li>
<li><p>Open the Actions dropdown and click Confirm account</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/158fb773-9cb1-4c14-9fd7-49e4369ba7e3.png" alt=" Cognito Users list showing a user with &quot;Unconfirmed&quot; status" style="display:block;margin:0 auto" width="1100" height="190" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/5637ac80-ee0c-4fdf-93cf-d4b7d71f6a65.png" alt="Cognito Users list showing a user with &quot;Unconfirmed&quot; status" style="display:block;margin:0 auto" width="1100" height="442" loading="lazy">

<h4 id="heading-401-unauthorized-errors-after-deployment">401 Unauthorized errors after deployment</h4>
<p>If you are getting 401 errors, check two things:</p>
<ol>
<li><p>Open Chrome DevTools --&gt; Network tab, click the failing request, and look at the <strong>Request Headers</strong>. You should see an <code>Authorization</code> header with a long string of characters. If it is missing, <code>getAuthToken</code> is failing. Check that Amplify is configured correctly in <code>providers.tsx</code> and wired in via <code>layout.tsx</code>.</p>
</li>
<li><p>In your CDK stack, confirm that <code>authorizationType: apigateway.AuthorizationType.COGNITO</code> is present on every protected method definition. If it is missing, API Gateway may not be checking tokens even though the authorizer is defined.</p>
</li>
</ol>
<h2 id="heading-part-9-deploy-the-frontend-with-s3-and-cloudfront">Part 9: Deploy the Frontend with S3 and CloudFront</h2>
<p>Your app works locally. Now you'll deploy it to a real HTTPS URL that anyone in the world can visit.</p>
<p><strong>The strategy:</strong> Next.js will export your React app as a set of static HTML, CSS, and JavaScript files. Those files will be uploaded to an <strong>S3 bucket</strong> (AWS's file storage service). <strong>CloudFront</strong> sits in front of the bucket as a Content Delivery Network (CDN), distributing your files to servers around the world and serving them over HTTPS.</p>
<h3 id="heading-91-configure-nextjs-for-static-export">9.1 Configure Next.js for Static Export</h3>
<p>Open <code>frontend/next.config.js</code> (or <code>next.config.mjs</code>) and add the <code>output: 'export'</code> setting:</p>
<pre><code class="language-javascript">/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'export', // Generates a static /out folder instead of a Node.js server
};

export default nextConfig;
</code></pre>
<p><strong>Note on 'use client' and static export</strong>: When output: 'export' is set, Next.js builds every page at compile time. Any component that uses browser-only APIs – like withAuthenticator from Amplify – must have 'use client' at the top of the file. This tells Next.js to skip server-side rendering for that component and run it only in the browser.</p>
<p>You already have 'use client' in page.tsx. If you ever see a build error mentioning window is not defined or similar, check that the relevant component has 'use client' at the top.</p>
<p>Build the frontend:</p>
<pre><code class="language-shell">cd frontend
npm run build
</code></pre>
<p>This generates an <code>/out</code> folder containing your complete website as static files. Verify the folder was created:</p>
<pre><code class="language-shell">ls out
# You should see: index.html, _next/, etc.
</code></pre>
<h3 id="heading-92-add-s3-and-cloudfront-to-the-cdk-stack">9.2 Add S3 and CloudFront to the CDK Stack</h3>
<p>Open <code>backend/lib/backend-stack.ts</code> and add the hosting infrastructure. Here's the complete final version of the file:</p>
<pre><code class="language-typescript">import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';

export class BackendStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 1. DynamoDB Table 
    const vendorTable = new dynamodb.Table(this, 'VendorTable', {
      partitionKey: { name: 'vendorId', type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // 2. Lambda Functions
    const lambdaEnv = { TABLE_NAME: vendorTable.tableName };

    const createVendorLambda = new NodejsFunction(this, 'CreateVendorHandler', {
      entry: 'lambda/createVendor.ts',
      handler: 'handler',
      environment: lambdaEnv,
    });

    const getVendorsLambda = new NodejsFunction(this, 'GetVendorsHandler', {
      entry: 'lambda/getVendors.ts',
      handler: 'handler',
      environment: lambdaEnv,
    });

    const deleteVendorLambda = new NodejsFunction(this, 'DeleteVendorHandler', {
      entry: 'lambda/deleteVendor.ts',
      handler: 'handler',
      environment: lambdaEnv,
    });

    // 3. Permissions
    vendorTable.grantWriteData(createVendorLambda);
    vendorTable.grantReadData(getVendorsLambda);
    vendorTable.grantWriteData(deleteVendorLambda);

    // 4. Cognito User Pool
    const userPool = new cognito.UserPool(this, 'VendorUserPool', {
      selfSignUpEnabled: true,
      signInAliases: { email: true },
      autoVerify: { email: true },
      userVerification: {
        emailStyle: cognito.VerificationEmailStyle.CODE,
      },
    });

    userPool.addDomain('VendorUserPoolDomain', {
      cognitoDomain: { domainPrefix: `vendor-tracker-${this.account}` },
    });

    const userPoolClient = userPool.addClient('VendorAppClient');

    // 5. API Gateway + Authorizer
    const api = new apigateway.RestApi(this, 'VendorApi', {
      restApiName: 'Vendor Service',
      defaultCorsPreflightOptions: {
        allowOrigins: apigateway.Cors.ALL_ORIGINS,
        allowMethods: apigateway.Cors.ALL_METHODS,
        allowHeaders: ['Content-Type', 'Authorization'],
      },
    });

    const authorizer = new apigateway.CognitoUserPoolsAuthorizer(
      this,
      'VendorAuthorizer',
      { cognitoUserPools: [userPool] }
    );

    const authOptions = {
      authorizer,
      authorizationType: apigateway.AuthorizationType.COGNITO,
    };

    const vendors = api.root.addResource('vendors');
    vendors.addMethod('GET', new apigateway.LambdaIntegration(getVendorsLambda), authOptions);
    vendors.addMethod('POST', new apigateway.LambdaIntegration(createVendorLambda), authOptions);
    vendors.addMethod('DELETE', new apigateway.LambdaIntegration(deleteVendorLambda), authOptions);

    // 6. S3 Bucket (Frontend Files) 
    const siteBucket = new s3.Bucket(this, 'VendorSiteBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // 7. CloudFront Distribution (HTTPS + CDN)
    const distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
      defaultBehavior: {
        origin: new origins.S3Origin(siteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          // Redirect all 404s back to index.html so React can handle routing
          httpStatus: 404,
          responseHttpStatus: 200,
          responsePagePath: '/index.html',
        },
      ],
    });

    // 8. Deploy Frontend Files to S3 
    new s3deploy.BucketDeployment(this, 'DeployWebsite', {
      sources: [s3deploy.Source.asset('../frontend/out')],
      destinationBucket: siteBucket,
      distribution,
      distributionPaths: ['/*'], // Clears CloudFront cache on every deploy
    });

    // 9. Outputs ───────────────────────────────────────────────────────────
    new cdk.CfnOutput(this, 'ApiEndpoint', { value: api.url });
    new cdk.CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
    new cdk.CfnOutput(this, 'UserPoolClientId', { value: userPoolClient.userPoolClientId });
    new cdk.CfnOutput(this, 'CloudFrontURL', {
      value: `https://${distribution.distributionDomainName}`,
    });
  }
}
</code></pre>
<p><strong>What the hosting infrastructure does:</strong></p>
<ul>
<li><p>The <strong>S3 bucket</strong> stores your static HTML, CSS, and JavaScript files. It is private – users cannot access it directly.</p>
</li>
<li><p><strong>CloudFront</strong> is the CDN that sits in front of S3. It gives you an HTTPS URL and caches your files at edge locations worldwide, so the app loads fast no matter where users are located. <code>REDIRECT_TO_HTTPS</code> automatically upgrades any HTTP request to HTTPS.</p>
</li>
<li><p>The <strong>error response</strong> for 404 returns <code>index.html</code> instead of an error page. This is necessary for single-page apps: if a user navigates directly to a route like <code>/vendors/123</code>, CloudFront cannot find a file at that path, but sending back <code>index.html</code> lets the React app handle the routing correctly.</p>
</li>
<li><p><code>distributionPaths: ['/*']</code> tells CloudFront to invalidate its entire cache after every deployment. This ensures users always see the latest version of your app immediately.</p>
</li>
<li><p><code>BucketDeployment</code> is a CDK construct that automatically uploads the contents of your <code>frontend/out</code> folder to the S3 bucket every time you run <code>cdk deploy</code>.</p>
</li>
</ul>
<h3 id="heading-93-run-the-final-deployment">9.3 Run the Final Deployment</h3>
<p>First, build the frontend with the latest environment variables:</p>
<pre><code class="language-shell">cd frontend
npm run build
</code></pre>
<p>Then deploy everything from the backend folder:</p>
<pre><code class="language-shell">cd ../backend
cdk deploy
</code></pre>
<p>After deployment finishes, copy the <code>CloudFrontURL</code> from the terminal output:</p>
<pre><code class="language-plaintext">Outputs:
BackendStack.CloudFrontURL = https://d1234abcd.cloudfront.net
</code></pre>
<p>Open that URL in your browser. Your app is now live on the internet, served over HTTPS, globally distributed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62d53ab5bc2c7a1dc672b04f/f8e14979-a667-4afc-bdd4-9afe4abd9593.png" alt="f8e14979-a667-4afc-bdd4-9afe4abd9593" style="display:block;margin:0 auto" width="1686" height="804" loading="lazy">

<h2 id="heading-what-you-built">What You Built</h2>
<p>You now have a fully deployed, production-style full-stack application. Here is a summary of every piece you built and what it does:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Service</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td>Frontend</td>
<td>Next.js + CloudFront</td>
<td>React UI served globally over HTTPS</td>
</tr>
<tr>
<td>Auth</td>
<td>Amazon Cognito + Amplify</td>
<td>User sign-up, login, and JWT token management</td>
</tr>
<tr>
<td>API</td>
<td>API Gateway</td>
<td>Routes HTTP requests, validates auth tokens</td>
</tr>
<tr>
<td>Logic</td>
<td>AWS Lambda (×3)</td>
<td>Creates, reads, and deletes vendors on demand</td>
</tr>
<tr>
<td>Database</td>
<td>DynamoDB</td>
<td>Stores vendor records with no idle cost</td>
</tr>
<tr>
<td>Storage</td>
<td>S3</td>
<td>Holds your built frontend files</td>
</tr>
<tr>
<td>Infrastructure</td>
<td>AWS CDK</td>
<td>Defines and deploys all of the above as code</td>
</tr>
</tbody></table>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You have built and deployed the foundational pattern of almost every cloud application: a secured API backed by a database, deployed with infrastructure as code. Here is everything you accomplished:</p>
<p>You set up a professional AWS development environment with scoped IAM credentials. You defined your entire backend infrastructure as TypeScript code using AWS CDK, which means your database, API, Lambda functions, and authentication system are all version-controlled, repeatable, and deployable with a single command.</p>
<p>You wrote three Lambda functions that handle create, read, and delete operations, each with proper error handling and the correct AWS SDK v3 patterns. You connected them to a REST API through API Gateway and protected every route with Amazon Cognito authentication, so only registered, verified users can interact with your data.</p>
<p>On the frontend, you built a Next.js application with a service layer that cleanly separates API logic from UI components, manages JWTs automatically through AWS Amplify, and gives users a complete sign-up and sign-in flow without you writing a single line of authentication UI code.</p>
<p>Finally, you deployed the entire system: your backend to AWS Lambda and DynamoDB, and your frontend as a static site served globally through CloudFront over HTTPS.</p>
<p>The full source code for this tutorial is available on <a href="https://github.com/BenedictaUche/vendor-tracker">GitHub</a>. Clone it, modify it, and use it as a reference for your own projects.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
