<?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[ Mayur Vekariya - 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[ Mayur Vekariya - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 24 May 2026 22:23:52 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/mayur9210/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build MCP Servers for Your Internal Data ]]>
                </title>
                <description>
                    <![CDATA[ The Model Context Protocol (MCP) is changing how AI applications connect to external tools and data. While some tutorials stop at "connect to GitHub" or "read a file," the real power of MCP is unlocki ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-mcp-servers-for-your-internal-data/</link>
                <guid isPermaLink="false">69a849e9e55311e40f03f00e</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mcp server ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mayur Vekariya ]]>
                </dc:creator>
                <pubDate>Wed, 04 Mar 2026 15:04:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/2e428238-cbc3-4892-97df-c1dd854c74c3.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The Model Context Protocol (MCP) is changing how AI applications connect to external tools and data. While some tutorials stop at "connect to GitHub" or "read a file," the real power of MCP is unlocking your <em>internal</em> data—databases, internal APIs, knowledge bases, and proprietary systems—for AI assistants in a structured, secure way.</p>
<p>In this guide, I'll walk you through building production-grade MCP servers that expose your organization's internal data to AI models. We'll go beyond simple examples and cover authentication, multi-tenancy, streaming, and deployment patterns you'll actually need.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#what-is-mcp-and-why-does-it-matter-for-internal-data">What is MCP and Why Does It Matter for Internal Data?</a></p>
</li>
<li><p><a href="#architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#setting-up-the-project">Setting Up the Project</a></p>
</li>
<li><p><a href="#building-the-mcp-server">Building the MCP Server</a></p>
<ul>
<li><p><a href="#step-1-server-skeleton">Step 1: Server Skeleton</a></p>
</li>
<li><p><a href="#step-2-connecting-to-internal-data">Step 2: Connecting to Internal Data</a></p>
</li>
<li><p><a href="#step-3-defining-tools">Step 3: Defining Tools</a></p>
</li>
<li><p><a href="#tool-design-principles">Tool Design Principles</a></p>
</li>
<li><p><a href="#step-4-exposing-resources">Step 4: Exposing Resources</a></p>
</li>
<li><p><a href="#step-5-transport-and-startup">Step 5: Transport and Startup</a></p>
</li>
</ul>
</li>
<li><p><a href="#adding-authentication">Adding Authentication</a></p>
<ul>
<li><p><a href="#bearer-token-authentication">Bearer Token Authentication</a></p>
</li>
<li><p><a href="#oauth-20-for-mcp">OAuth 2.0 for MCP</a></p>
</li>
</ul>
</li>
<li><p><a href="#scoping-data-access-per-user">Scoping Data Access Per User</a></p>
</li>
<li><p><a href="#connecting-to-internal-apis">Connecting to Internal APIs</a></p>
</li>
<li><p><a href="#building-a-rag-tool-for-internal-documents">Building a RAG Tool for Internal Documents</a></p>
</li>
<li><p><a href="#production-deployment">Production Deployment</a></p>
<ul>
<li><p><a href="#dockerizing-the-mcp-server">Dockerizing the MCP Server</a></p>
</li>
<li><p><a href="#health-checks-and-monitoring">Health Checks and Monitoring</a></p>
</li>
<li><p><a href="#logging-and-audit-trail">Logging and Audit Trail</a></p>
</li>
</ul>
</li>
<li><p><a href="#connecting-your-mcp-server-to-ai-clients">Connecting Your MCP Server to AI Clients</a></p>
<ul>
<li><p><a href="#claude-desktop">Claude Desktop</a></p>
</li>
<li><p><a href="#custom-application-using-the-mcp-client-sdk">Custom Application (using the MCP Client SDK)</a></p>
</li>
</ul>
</li>
<li><p><a href="#common-pitfalls">Common Pitfalls</a></p>
</li>
<li><p><a href="#wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This is an advanced guide. You should be comfortable with:</p>
<ul>
<li><p>TypeScript / Node.js</p>
</li>
<li><p>REST APIs and server-side development</p>
</li>
<li><p>Basic understanding of LLMs and tool calling</p>
</li>
<li><p>Familiarity with protocols like JSON-RPC</p>
</li>
</ul>
<h2 id="heading-what-is-mcp-and-why-does-it-matter-for-internal-data">What is MCP, and Why Does It Matter for Internal Data?</h2>
<p>MCP is an open protocol (created by Anthropic) that standardizes how AI assistants discover and invoke external tools. Think of it as a USB-C port for AI — one standard interface that lets any AI model connect to any data source.</p>
<p>Before MCP, connecting an AI assistant to your internal database meant:</p>
<ul>
<li><p>Writing custom tool definitions for each LLM provider</p>
</li>
<li><p>Hardcoding data access logic into your AI application</p>
</li>
<li><p>Rebuilding everything when you switched models or added new data sources</p>
</li>
</ul>
<p>MCP separates the <em>data layer</em> from the <em>AI layer</em>. Your MCP server exposes tools and resources. Any MCP-compatible client—Claude, ChatGPT, your custom app—can use them without modification.</p>
<p>For internal data, this is significant because:</p>
<ul>
<li><p><strong>Your CRM, ERP, ticketing system, and wiki all become AI-accessible</strong> through one protocol</p>
</li>
<li><p><strong>Access control stays in your MCP server</strong>, not scattered across AI application code</p>
</li>
<li><p><strong>New AI models or clients automatically get access</strong> without rewiring integrations</p>
</li>
<li><p><strong>Tool definitions live close to the data</strong>, making them easier to maintain and version</p>
</li>
</ul>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<p>Here's what we're building:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6763484155ac493748dfb95b/3b8f4b25-aa15-4cf1-971d-94f2f7ee9e70.png" alt="MCP server architecture connecting an AI client to internal data sources — PostgreSQL, Internal API, and File Store — via JSON-RPC over HTTP/SSE/stdio." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The MCP server sits between your AI client and your internal systems. It handles:</p>
<ul>
<li><p><strong>Tool discovery</strong>: Tells the AI what operations are available</p>
</li>
<li><p><strong>Parameter validation</strong>: Ensures the AI sends correct inputs</p>
</li>
<li><p><strong>Data access</strong>: Queries your internal systems</p>
</li>
<li><p><strong>Response formatting</strong>: Returns structured data the AI can reason about</p>
</li>
<li><p><strong>Authentication</strong>: Verifies who's making the request</p>
</li>
</ul>
<h2 id="heading-setting-up-the-project">Setting Up the Project</h2>
<p>Let's build an MCP server that exposes an internal employee directory and project management system.</p>
<pre><code class="language-shell">mkdir internal-data-mcp &amp;&amp; cd internal-data-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod express pg
npm install -D typescript @types/node @types/express @types/pg tsx
</code></pre>
<p>These commands scaffold the project. <code>npm install</code> pulls in the runtime dependencies: the official MCP SDK, Zod for schema validation, Express for the HTTP server, and <code>pg</code> for PostgreSQL. The <code>-D</code> flag installs TypeScript and its type definitions as dev-only dependencies — they're needed to compile the code but don't ship to production. <code>tsx</code> lets you run TypeScript directly during development without a separate compile step.</p>
<p>Now, create your <code>tsconfig.json</code>:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}
</code></pre>
<p>This TypeScript config targets ES2022, which supports modern JavaScript features like top-level <code>await</code>. <code>"module": "Node16"</code> and <code>"moduleResolution": "Node16"</code> are required when using the MCP SDK's <code>.js</code> import extensions. <code>"strict": true</code> enables all of TypeScript's strictness checks, which helps catch bugs in tool handlers before they reach production. The <code>outDir</code>/<code>rootDir</code> pair tells the compiler to take source files from <code>src/</code> and emit compiled JavaScript into <code>dist/</code>.</p>
<h2 id="heading-building-the-mcp-server">Building the MCP Server</h2>
<h3 id="heading-step-1-server-skeleton">Step 1: Server Skeleton</h3>
<p>Create <code>src/server.ts</code>:</p>
<pre><code class="language-typescript">import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer(
  { name: "internal-data", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);
</code></pre>
<p>The <code>McpServer</code> class from the official SDK handles the JSON-RPC protocol, transport negotiation, and lifecycle management. We declare support for both <code>tools</code> (actions the AI can take) and <code>resources</code> (data the AI can read).</p>
<h3 id="heading-step-2-connecting-to-internal-data">Step 2: Connecting to Internal Data</h3>
<p>Let's say you have a PostgreSQL database with employee and project data. Create a data access layer:</p>
<pre><code class="language-typescript">// src/db.ts
import pg from "pg";

const pool = new pg.Pool({
  connectionString: process.env.INTERNAL_DB_URL,
  max: 10,
  idleTimeoutMillis: 30000,
});

export interface Employee {
  id: string;
  name: string;
  email: string;
  department: string;
  role: string;
  manager_id: string | null;
  start_date: string;
}

export interface Project {
  id: string;
  name: string;
  status: "active" | "completed" | "on_hold";
  lead_id: string;
  department: string;
  deadline: string | null;
}

export async function searchEmployees(
  query: string,
  department?: string
): Promise&lt;Employee[]&gt; {
  const conditions = ["(name ILIKE \(1 OR email ILIKE \)1 OR role ILIKE $1)"];
  const params: string[] = [`%${query}%`];

  if (department) {
    conditions.push(`department = $${params.length + 1}`);
    params.push(department);
  }

  const result = await pool.query&lt;Employee&gt;(
    `SELECT id, name, email, department, role, manager_id, start_date
     FROM employees
     WHERE ${conditions.join(" AND ")}
     ORDER BY name
     LIMIT 25`,
    params
  );

  return result.rows;
}

export async function getProjectsByStatus(
  status: string
): Promise&lt;Project[]&gt; {
  const result = await pool.query&lt;Project&gt;(
    `SELECT id, name, status, lead_id, department, deadline
     FROM projects
     WHERE status = $1
     ORDER BY deadline ASC NULLS LAST`,
    [status]
  );

  return result.rows;
}

export async function getProjectMembers(
  projectId: string
): Promise&lt;Employee[]&gt; {
  const result = await pool.query&lt;Employee&gt;(
    `SELECT e.id, e.name, e.email, e.department, e.role,
            e.manager_id, e.start_date
     FROM employees e
     JOIN project_members pm ON pm.employee_id = e.id
     WHERE pm.project_id = $1
     ORDER BY e.name`,
    [projectId]
  );

  return result.rows;
}
</code></pre>
<p>Notice this is plain SQL with parameterized queries. Your MCP server's data access layer should use whatever your team already uses — Prisma, Drizzle, Knex, raw SQL. MCP doesn't dictate your data access patterns.</p>
<h3 id="heading-step-3-defining-tools">Step 3: Defining Tools</h3>
<p>Now expose this data through MCP tools. This is where the design matters most. Good tool definitions directly impact how well the AI uses your data.</p>
<pre><code class="language-typescript">// src/tools.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
  searchEmployees,
  getProjectsByStatus,
  getProjectMembers,
} from "./db.js";

export function registerTools(server: McpServer) {
  // Tool 1: Search the employee directory
  server.tool(
    "search_employees",
    `Search the internal employee directory by name, email, or role.
     Returns matching employees with their department and reporting structure.
     Use this when the user asks about people, teams, or org structure.`,
    {
      query: z
        .string()
        .describe("Search term: employee name, email, or role title"),
      department: z
        .string()
        .optional()
        .describe(
          "Filter by department name (e.g., 'Engineering', 'Marketing')"
        ),
    },
    async ({ query, department }) =&gt; {
      const employees = await searchEmployees(query, department);

      if (employees.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No employees found matching "\({query}"\){department ? ` in ${department}` : ""}.`,
            },
          ],
        };
      }

      const formatted = employees
        .map(
          (e) =&gt;
            `- **\({e.name}** (\){e.email})\n  Role: \({e.role} | Dept: \){e.department} | Since: ${e.start_date}`
        )
        .join("\n");

      return {
        content: [
          {
            type: "text",
            text: `Found \({employees.length} employee(s):\n\n\){formatted}`,
          },
        ],
      };
    }
  );

  // Tool 2: List projects by status
  server.tool(
    "list_projects",
    `List internal projects filtered by status.
     Returns project name, lead, department, and deadline.
     Use this when the user asks about ongoing work, project status, or deadlines.`,
    {
      status: z
        .enum(["active", "completed", "on_hold"])
        .describe("Project status to filter by"),
    },
    async ({ status }) =&gt; {
      const projects = await getProjectsByStatus(status);

      if (projects.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: `No ${status} projects found.`,
            },
          ],
        };
      }

      const formatted = projects
        .map(
          (p) =&gt;
            `- **\({p.name}** [\){p.status}]\n  Lead: \({p.lead_id} | Dept: \){p.department} | Deadline: ${p.deadline ?? "None"}`
        )
        .join("\n");

      return {
        content: [
          {
            type: "text",
            text: `\({projects.length} \){status} project(s):\n\n${formatted}`,
          },
        ],
      };
    }
  );

  // Tool 3: Get team members for a project
  server.tool(
    "get_project_team",
    `Get all team members assigned to a specific project.
     Returns employee details for each member.
     Use this when the user asks who is working on a project.`,
    {
      project_id: z
        .string()
        .uuid()
        .describe("The UUID of the project to look up"),
    },
    async ({ project_id }) =&gt; {
      const members = await getProjectMembers(project_id);

      if (members.length === 0) {
        return {
          content: [
            {
              type: "text",
              text: "No team members found for this project.",
            },
          ],
        };
      }

      const formatted = members
        .map((m) =&gt; `- \({m.name} (\){m.role}, ${m.department})`)
        .join("\n");

      return {
        content: [
          {
            type: "text",
            text: `Project team (\({members.length} members):\n\n\){formatted}`,
          },
        ],
      };
    }
  );
}
</code></pre>
<p><code>server.tool()</code> registers each tool with four arguments: the tool name, a plain-English description the AI reads to decide when to call it, a Zod schema defining the parameters, and the async handler that runs when the tool is invoked. The handler receives validated, typed parameters — Zod rejects malformed inputs before your handler ever runs. Each handler returns a <code>content</code> array; the <code>type: "text"</code> block is the most common format and tells the AI client to treat the response as readable text. Returning an empty result (zero matches) is handled explicitly so the AI gets a useful message rather than an empty array it might misinterpret.</p>
<h3 id="heading-tool-design-principles">Tool Design Principles</h3>
<p>Three things make the difference between tools an AI uses well and tools it struggles with:</p>
<p><strong>1. Descriptive names and descriptions.</strong> The AI decides which tool to call based entirely on the description. Be specific about <em>when</em> to use the tool, not just <em>what</em> it does. Compare:</p>
<pre><code class="language-plaintext">// Vague — the AI won't know when to pick this
"Search employees"

// Specific — the AI knows exactly when this tool is relevant
"Search the internal employee directory by name, email, or role.
 Use this when the user asks about people, teams, or org structure."
</code></pre>
<p><strong>2. Typed parameters with descriptions.</strong> Use Zod's <code>.describe()</code> on every parameter. The AI needs to understand what each field expects:</p>
<pre><code class="language-typescript">// The AI has to guess what format "query" expects
{ query: z.string() }

// The AI knows exactly what to pass
{ query: z.string().describe("Search term: employee name, email, or role title") }
</code></pre>
<p><strong>3. Structured return values.</strong> Return data in a format the AI can reason about. Use markdown tables or structured lists rather than raw JSON dumps. The AI processes structured text better than deeply nested objects.</p>
<h3 id="heading-step-4-exposing-resources">Step 4: Exposing Resources</h3>
<p>Resources are read-only data the AI can pull into its context. Unlike tools (which the AI invokes during reasoning), resources are typically loaded upfront to provide background knowledge.</p>
<pre><code class="language-typescript">// src/resources.ts
import {
  McpServer,
  ResourceTemplate,
} from "@modelcontextprotocol/sdk/server/mcp.js";

export function registerResources(server: McpServer) {
  // Static resource: org chart overview
  server.resource(
    "org-structure",
    "internal://org-structure",
    {
      description:
        "Overview of the organization structure including departments and leadership",
      mimeType: "text/markdown",
    },
    async (uri) =&gt; ({
      contents: [
        {
          uri: uri.href,
          mimeType: "text/markdown",
          text: await generateOrgOverview(),
        },
      ],
    })
  );

  // Dynamic resource template: department details
  server.resource(
    "department-info",
    new ResourceTemplate("internal://departments/{name}", {
      list: undefined,
    }),
    {
      description: "Detailed information about a specific department",
      mimeType: "text/markdown",
    },
    async (uri, variables) =&gt; ({
      contents: [
        {
          uri: uri.href,
          mimeType: "text/markdown",
          text: await getDepartmentDetails(
            variables.name as string
          ),
        },
      ],
    })
  );
}
</code></pre>
<p><code>server.resource()</code> registers two kinds of resources here. The first uses a fixed URI (<code>internal://org-structure</code>) — this is a static resource the AI can request by name. The second uses a <code>ResourceTemplate</code>, which defines a URI pattern with a <code>{name}</code> placeholder; the AI can request <code>internal://departments/Engineering</code> and the <code>variables.name</code> parameter will be populated with <code>"Engineering"</code> at runtime. Both resources return a <code>contents</code> array with <code>mimeType: "text/markdown"</code> — this tells the client how to render the response. Resources differ from tools in that they're meant to be read as background context, not invoked as actions.</p>
<p>Resources are useful for data that provides context rather than answering a specific question — company policies, API documentation, database schemas, configuration references.</p>
<h3 id="heading-step-5-transport-and-startup">Step 5: Transport and Startup</h3>
<p>MCP supports multiple transports. For internal data servers, you'll typically use one of two:</p>
<p><strong>Streamable HTTP</strong> — the recommended transport for remote servers (replaces the older SSE transport):</p>
<pre><code class="language-typescript">// src/index.ts
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { registerTools } from "./tools.js";
import { registerResources } from "./resources.js";

const app = express();
app.use(express.json());

const server = new McpServer(
  { name: "internal-data", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);

registerTools(server);
registerResources(server);

// Store transports by session ID
const transports = new Map&lt;string, StreamableHTTPServerTransport&gt;();

// Handle all MCP requests on a single endpoint
app.all("/mcp", async (req, res) =&gt; {
  // Check for existing session
  const sessionId = req.headers["mcp-session-id"] as string | undefined;

  if (sessionId &amp;&amp; transports.has(sessionId)) {
    // Existing session — route to its transport
    const transport = transports.get(sessionId)!;
    await transport.handleRequest(req, res);
    return;
  }

  if (sessionId &amp;&amp; !transports.has(sessionId)) {
    // Unknown session ID
    res.status(404).json({ error: "Session not found" });
    return;
  }

  // New session — create transport and connect
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () =&gt; randomUUID(),
    onsessioninitialized: (id) =&gt; {
      transports.set(id, transport);
    },
  });

  transport.onclose = () =&gt; {
    if (transport.sessionId) {
      transports.delete(transport.sessionId);
    }
  };

  await server.connect(transport);
  await transport.handleRequest(req, res);
});

app.listen(3100, () =&gt; {
  console.log("MCP server running on http://localhost:3100/mcp");
});
</code></pre>
<p>This sets up a single <code>/mcp</code> endpoint that handles all MCP communication. When a new client connects (no <code>mcp-session-id</code> header), a <code>StreamableHTTPServerTransport</code> is created and stored in the <code>transports</code> Map keyed by a generated UUID. On subsequent requests, the session ID from the header is used to look up the existing transport and route the request to it — this is how the server maintains stateful sessions with multiple clients simultaneously. <code>transport.onclose</code> cleans up the Map entry when a session ends, preventing memory leaks. The <code>StdioServerTransport</code> alternative (shown below) skips all of this: it reads from stdin and writes to stdout, which is how Claude Desktop spawns local servers as child processes.</p>
<p><strong>Stdio</strong> — for local development or when the MCP client spawns the server as a child process:</p>
<pre><code class="language-typescript">// src/stdio.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { registerTools } from "./tools.js";
import { registerResources } from "./resources.js";

const server = new McpServer(
  { name: "internal-data", version: "1.0.0" },
  { capabilities: { tools: {}, resources: {} } }
);

registerTools(server);
registerResources(server);

const transport = new StdioServerTransport();
await server.connect(transport);
</code></pre>
<p>For internal data in a production setting, HTTP/SSE is almost always what you want. Stdio is convenient for development and when the client and server run on the same machine.</p>
<h2 id="heading-adding-authentication">Adding Authentication</h2>
<p>Internal data servers need authentication. You don't want every AI client on the network querying your employee database unauthenticated.</p>
<h3 id="heading-bearer-token-authentication">Bearer Token Authentication</h3>
<p>The simplest approach is to validate a token on every request:</p>
<pre><code class="language-typescript">// src/auth-middleware.ts
import { Request, Response, NextFunction } from "express";

interface AuthenticatedRequest extends Request {
  userId?: string;
  orgId?: string;
}

export function authMiddleware(
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith("Bearer ")) {
    return res.status(401).json({ error: "Missing authorization header" });
  }

  const token = authHeader.slice(7);

  try {
    // Validate against your internal auth system
    const claims = validateInternalToken(token);
    req.userId = claims.sub;
    req.orgId = claims.org;
    next();
  } catch {
    return res.status(403).json({ error: "Invalid token" });
  }
}

function validateInternalToken(token: string) {
  // Replace with your actual token validation:
  // - JWT verification against your auth service
  // - API key lookup in your database
  // - Session token validation against Redis
  // This is a placeholder
  return { sub: "user-123", org: "org-456" };
}
</code></pre>
<p>The middleware checks every request for an <code>Authorization: Bearer &lt;token&gt;</code> header before it reaches the MCP handler. <code>validateInternalToken</code> is a placeholder — replace it with your real validation logic: JWT verification using a library like <code>jsonwebtoken</code>, an API key lookup in your database, or a session token check against Redis. The validated claims are attached to the request object (<code>req.userId</code>, <code>req.orgId</code>) so downstream tool handlers can use them for access scoping. The <code>app.use("/mcp", authMiddleware)</code> line ensures no request reaches the MCP endpoint without passing this check first.</p>
<p>Add it to your Express app:</p>
<pre><code class="language-typescript">app.use("/mcp", authMiddleware);
</code></pre>
<h3 id="heading-oauth-20-for-mcp">OAuth 2.0 for MCP</h3>
<p>For clients that support MCP's built-in OAuth flow (like Claude Desktop), you can implement the full OAuth handshake. The MCP SDK provides the <code>OAuthServerProvider</code> interface with these required methods:</p>
<pre><code class="language-typescript">import type { OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js";
import type {
  AuthorizationParams,
  OAuthClientInformationFull,
  OAuthRegisteredClientsStore,
  OAuthTokens,
  AuthInfo,
} from "@modelcontextprotocol/sdk/server/auth/types.js";

class InternalOAuthProvider implements OAuthServerProvider {
  // Store for registered OAuth clients
  get clientsStore(): OAuthRegisteredClientsStore {
    return this._clientsStore;
  }

  private _clientsStore: OAuthRegisteredClientsStore = {
    async getClient(clientId: string) {
      // Look up the registered client in your database
      return db.getOAuthClient(clientId);
    },
    async registerClient(clientMetadata) {
      // Register a new dynamic client
      return db.createOAuthClient(clientMetadata);
    },
  };

  // Redirect the user to your internal SSO for authorization
  async authorize(
    client: OAuthClientInformationFull,
    params: AuthorizationParams,
    res: Response
  ): Promise&lt;void&gt; {
    const authUrl = new URL(
      "https://sso.internal.company.com/authorize"
    );
    authUrl.searchParams.set("client_id", client.client_id);
    authUrl.searchParams.set("redirect_uri", params.redirectUri);
    authUrl.searchParams.set("state", params.state ?? "");
    authUrl.searchParams.set(
      "code_challenge",
      params.codeChallenge
    );
    // The method writes to the response directly
    res.redirect(authUrl.toString());
  }

  // Return the PKCE challenge for a given authorization code
  async challengeForAuthorizationCode(
    _client: OAuthClientInformationFull,
    authorizationCode: string
  ): Promise&lt;string&gt; {
    const session = await db.getSessionByCode(authorizationCode);
    return session.codeChallenge;
  }

  // Exchange authorization code for access + refresh tokens
  async exchangeAuthorizationCode(
    client: OAuthClientInformationFull,
    authorizationCode: string,
    _codeVerifier?: string,
    _redirectUri?: string
  ): Promise&lt;OAuthTokens&gt; {
    const response = await fetch(
      "https://sso.internal.company.com/token",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          grant_type: "authorization_code",
          code: authorizationCode,
          client_id: client.client_id,
        }),
      }
    );

    return response.json() as Promise&lt;OAuthTokens&gt;;
  }

  // Refresh expired tokens
  async exchangeRefreshToken(
    client: OAuthClientInformationFull,
    refreshToken: string
  ): Promise&lt;OAuthTokens&gt; {
    const response = await fetch(
      "https://sso.internal.company.com/token",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({
          grant_type: "refresh_token",
          refresh_token: refreshToken,
          client_id: client.client_id,
        }),
      }
    );

    return response.json() as Promise&lt;OAuthTokens&gt;;
  }

  // Validate an access token on every request
  async verifyAccessToken(token: string): Promise&lt;AuthInfo&gt; {
    const response = await fetch(
      "https://sso.internal.company.com/introspect",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({ token }),
      }
    );

    const data = await response.json();
    if (!data.active) throw new Error("Token inactive");

    return {
      token,
      clientId: data.client_id,
      scopes: data.scope?.split(" ") ?? [],
      expiresAt: data.exp,
    };
  }
}
</code></pre>
<p><code>InternalOAuthProvider</code> implements the <code>OAuthServerProvider</code> interface, which the MCP SDK calls at each stage of the OAuth flow. <code>clientsStore</code> handles dynamic client registration — MCP clients like Claude Desktop register themselves the first time they connect. <code>authorize()</code> redirects the user to your internal SSO; it writes directly to the Express response. <code>challengeForAuthorizationCode()</code> returns the PKCE code challenge stored when the authorization session began — this is how the token exchange is verified without transmitting secrets. <code>exchangeAuthorizationCode()</code> and <code>exchangeRefreshToken()</code> make server-to-server calls to your SSO's token endpoint, keeping credentials out of the browser. <code>verifyAccessToken()</code> is called on every incoming MCP request using the token introspection endpoint to confirm the token is still active and extract the user's scopes.</p>
<h2 id="heading-scoping-data-access-per-user">Scoping Data Access Per User</h2>
<p>This is the most important part of an internal data MCP server: <strong>the AI should only access data the requesting user is authorized to see.</strong></p>
<p>Don't skip this. Without user-scoped access, you're building a data exfiltration tool with an AI wrapper.</p>
<pre><code class="language-typescript">// src/scoped-tools.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function registerScopedTools(
  server: McpServer,
  getUserContext: () =&gt; { userId: string; orgId: string; role: string }
) {
  server.tool(
    "search_employees",
    "Search the employee directory. Results are filtered based on your access level.",
    {
      query: z.string().describe("Name, email, or role to search for"),
    },
    async ({ query }) =&gt; {
      const ctx = getUserContext();

      // Enforce access boundaries
      let departmentFilter: string | undefined;

      if (ctx.role === "manager") {
        // Managers see their department only
        departmentFilter = await getUserDepartment(ctx.userId);
      } else if (ctx.role === "employee") {
        // Regular employees see limited fields
        departmentFilter = await getUserDepartment(ctx.userId);
      }
      // Admins and HR see everything — no filter

      const employees = await searchEmployees(query, departmentFilter);

      // Redact sensitive fields based on role
      const results = employees.map((e) =&gt; ({
        name: e.name,
        email: e.email,
        department: e.department,
        role: e.role,
        // Only HR and admins see start date and manager info
        ...(["admin", "hr"].includes(ctx.role)
          ? { start_date: e.start_date, manager_id: e.manager_id }
          : {}),
      }));

      return {
        content: [
          {
            type: "text",
            text: formatEmployeeList(results),
          },
        ],
      };
    }
  );
}
</code></pre>
<p>The pattern here:</p>
<ol>
<li><p><strong>Extract user context</strong> from the authenticated session</p>
</li>
<li><p><strong>Filter queries</strong> at the database level (not after fetching everything)</p>
</li>
<li><p><strong>Redact fields</strong> the user shouldn't see</p>
</li>
<li><p><strong>Log access</strong> for audit trails</p>
</li>
</ol>
<h2 id="heading-connecting-to-internal-apis">Connecting to Internal APIs</h2>
<p>Not all internal data lives in databases. You often need to wrap existing internal APIs:</p>
<pre><code class="language-typescript">server.tool(
  "get_ticket_details",
  `Look up a support ticket from the internal ticketing system.
   Returns ticket status, assignee, priority, and recent updates.`,
  {
    ticket_id: z
      .string()
      .regex(/^TK-\d+$/)
      .describe("Ticket ID in format TK-12345"),
  },
  async ({ ticket_id }) =&gt; {
    const ctx = getUserContext();

    const response = await fetch(
      `\({process.env.TICKETING_API_URL}/api/v2/tickets/\){ticket_id}`,
      {
        headers: {
          Authorization: `Bearer ${process.env.TICKETING_SERVICE_TOKEN}`,
          "X-On-Behalf-Of": ctx.userId,
        },
      }
    );

    if (response.status === 404) {
      return {
        content: [
          { type: "text", text: `Ticket ${ticket_id} not found.` },
        ],
      };
    }

    if (response.status === 403) {
      return {
        content: [
          {
            type: "text",
            text: `You don't have access to ticket ${ticket_id}.`,
          },
        ],
      };
    }

    const ticket = await response.json();

    return {
      content: [
        {
          type: "text",
          text: [
            `**\({ticket.id}: \){ticket.title}**`,
            `Status: \({ticket.status} | Priority: \){ticket.priority}`,
            `Assignee: ${ticket.assignee?.name ?? "Unassigned"}`,
            `Created: ${ticket.created_at}`,
            "",
            `**Latest Update:**`,
            ticket.updates?.[0]?.body ?? "No updates yet.",
          ].join("\n"),
        },
      ],
    };
  }
);
</code></pre>
<p>Key points when wrapping internal APIs:</p>
<ul>
<li><p><strong>Use service tokens</strong> for server-to-server auth, but pass user identity via headers like <code>X-On-Behalf-Of</code></p>
</li>
<li><p><strong>Handle HTTP errors explicitly</strong> — return user-friendly messages, not raw error objects</p>
</li>
<li><p><strong>Validate input formats</strong> — the regex on <code>ticket_id</code> prevents injection and guides the AI on expected format</p>
</li>
<li><p><strong>Don't leak internal implementation details</strong> in error messages</p>
</li>
</ul>
<h2 id="heading-building-a-rag-tool-for-internal-documents">Building a RAG Tool for Internal Documents</h2>
<p>One of the highest-value use cases: letting the AI search your internal knowledge base. Here's a tool that performs vector search against an internal document store:</p>
<pre><code class="language-typescript">server.tool(
  "search_internal_docs",
  `Search the internal knowledge base for relevant documents.
   Covers engineering docs, runbooks, architecture decisions, and policies.
   Use this when the user asks about internal processes, systems, or decisions.`,
  {
    query: z
      .string()
      .describe("Natural language search query"),
    category: z
      .enum(["engineering", "policy", "runbook", "architecture", "all"])
      .default("all")
      .describe("Document category to search within"),
    limit: z
      .number()
      .min(1)
      .max(10)
      .default(5)
      .describe("Maximum number of results"),
  },
  async ({ query, category, limit }) =&gt; {
    // Generate embedding for the search query
    const embedding = await generateEmbedding(query);

    // Vector similarity search against your document store
    const results = await pool.query(
      `SELECT
         d.id,
         d.title,
         d.category,
         d.content_chunk,
         d.source_url,
         d.updated_at,
         1 - (d.embedding &lt;=&gt; $1::vector) AS similarity
       FROM document_chunks d
       WHERE (\(2 = 'all' OR d.category = \)2)
         AND 1 - (d.embedding &lt;=&gt; $1::vector) &gt; 0.7
       ORDER BY d.embedding &lt;=&gt; $1::vector
       LIMIT $3`,
      [JSON.stringify(embedding), category, limit]
    );

    if (results.rows.length === 0) {
      return {
        content: [
          {
            type: "text",
            text: `No relevant documents found for "${query}".`,
          },
        ],
      };
    }

    const formatted = results.rows
      .map(
        (doc, i) =&gt;
          `### \({i + 1}. \){doc.title}\n` +
          `Category: \({doc.category} | Updated: \){doc.updated_at} | Relevance: ${(doc.similarity * 100).toFixed(0)}%\n\n` +
          `${doc.content_chunk}\n\n` +
          `Source: ${doc.source_url}`
      )
      .join("\n\n---\n\n");

    return {
      content: [
        {
          type: "text",
          text: `Found \({results.rows.length} relevant document(s):\n\n\){formatted}`,
        },
      ],
    };
  }
);
</code></pre>
<p>This tool combines two operations: embedding generation and vector similarity search. <code>generateEmbedding(query)</code> calls an embedding model (such as OpenAI's <code>text-embedding-3-small</code> or a self-hosted model) to convert the user's query into a numeric vector. The SQL query then uses pgvector's <code>&lt;=&gt;</code> operator to compute cosine distance between the query vector and stored document chunk embeddings — lower distance means higher similarity. The <code>1 - (embedding &lt;=&gt; $1) &gt; 0.7</code> condition filters out results below 70% similarity, so the AI doesn't receive loosely related noise. Results are ordered by ascending distance (most similar first) and capped by the <code>limit</code> parameter. The formatted output includes a relevance percentage so the AI can communicate confidence levels to the user.</p>
<h2 id="heading-production-deployment">Production Deployment</h2>
<h3 id="heading-dockerizing-the-mcp-server">Dockerizing the MCP Server</h3>
<pre><code class="language-dockerfile">FROM node:22-slim AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-slim AS runtime

WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./

ENV NODE_ENV=production
EXPOSE 3100

HEALTHCHECK --interval=30s --timeout=5s \
  CMD curl -f http://localhost:3100/health || exit 1

CMD ["node", "dist/index.js"]
</code></pre>
<p>The Dockerfile uses a two-stage build. The <code>builder</code> stage installs all dependencies (including devDependencies) and compiles TypeScript to JavaScript in <code>dist/</code>. The <code>runtime</code> stage starts fresh from a clean Node image and copies only the compiled output and <code>node_modules</code> — devDependencies like TypeScript are excluded, keeping the final image small. The <code>HEALTHCHECK</code> instruction tells Docker (and orchestrators like Kubernetes) to poll <code>/health</code> every 30 seconds; if the endpoint fails, the container is marked unhealthy and can be automatically restarted or removed from the load balancer rotation.</p>
<h3 id="heading-health-checks-and-monitoring">Health Checks and Monitoring</h3>
<p>Add a health endpoint that verifies your dependencies:</p>
<pre><code class="language-typescript">app.get("/health", async (_req, res) =&gt; {
  const checks = {
    database: false,
    ticketingApi: false,
  };

  try {
    await pool.query("SELECT 1");
    checks.database = true;
  } catch {}

  try {
    const resp = await fetch(
      `${process.env.TICKETING_API_URL}/health`
    );
    checks.ticketingApi = resp.ok;
  } catch {}

  const healthy = Object.values(checks).every(Boolean);
  res.status(healthy ? 200 : 503).json({
    status: healthy ? "healthy" : "degraded",
    checks,
    uptime: process.uptime(),
  });
});
</code></pre>
<p>The <code>/health</code> endpoint runs two dependency checks in parallel: a lightweight <code>SELECT 1</code> query to confirm the database connection is live, and an HTTP ping to the ticketing API. Both results are collected into a <code>checks</code> object. If any check fails, the endpoint returns HTTP 503 (Service Unavailable) — this is the signal load balancers and container orchestrators use to stop routing traffic to an unhealthy instance. <code>process.uptime()</code> is included as a diagnostic field so you can quickly tell whether a degraded instance just started or has been running for hours.</p>
<h3 id="heading-logging-and-audit-trail">Logging and Audit Trail</h3>
<p>Every tool invocation against internal data should be logged:</p>
<pre><code class="language-typescript">function createAuditLogger() {
  return {
    logToolCall(params: {
      userId: string;
      tool: string;
      input: Record&lt;string, unknown&gt;;
      resultSize: number;
      durationMs: number;
    }) {
      // Ship to your logging infrastructure
      // (Datadog, ELK, CloudWatch, etc.)
      console.log(
        JSON.stringify({
          event: "mcp_tool_call",
          timestamp: new Date().toISOString(),
          ...params,
        })
      );
    },
  };
}
</code></pre>
<p><code>createAuditLogger</code> returns a logger object rather than a class instance, which makes it easy to swap the underlying transport (stdout, a logging SDK, etc.) without changing the call sites. The <code>audited</code> wrapper function is a higher-order function: it takes a tool handler and returns a new function with the same signature, but with timing and logging added around the original call. The <code>try/catch</code> ensures a log entry is written even when the handler throws — you want failed calls in your audit trail, not just successful ones. Shipping these logs to a centralized store (Datadog, CloudWatch, ELK) lets you answer questions like "what data did this user's AI session access last Tuesday?" — which is often required for compliance in organizations handling sensitive internal data.</p>
<p>Wrap your tool handlers to automatically log every call:</p>
<pre><code class="language-typescript">function audited&lt;T extends Record&lt;string, unknown&gt;&gt;(
  handler: (params: T) =&gt; Promise&lt;ToolResult&gt;,
  toolName: string,
  audit: ReturnType&lt;typeof createAuditLogger&gt;
) {
  return async (params: T): Promise&lt;ToolResult&gt; =&gt; {
    const start = Date.now();
    const ctx = getUserContext();

    try {
      const result = await handler(params);
      audit.logToolCall({
        userId: ctx.userId,
        tool: toolName,
        input: params,
        resultSize: JSON.stringify(result).length,
        durationMs: Date.now() - start,
      });
      return result;
    } catch (error) {
      audit.logToolCall({
        userId: ctx.userId,
        tool: toolName,
        input: params,
        resultSize: 0,
        durationMs: Date.now() - start,
      });
      throw error;
    }
  };
}
</code></pre>
<h2 id="heading-connecting-your-mcp-server-to-ai-clients">Connecting Your MCP Server to AI Clients</h2>
<h3 id="heading-claude-desktop">Claude Desktop</h3>
<p>Add to your <code>claude_desktop_config.json</code>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "internal-data": {
      "url": "http://localhost:3100/mcp",
      "headers": {
        "Authorization": "Bearer your-internal-token"
      }
    }
  }
}
</code></pre>
<h3 id="heading-custom-application-using-the-mcp-client-sdk">Custom Application (using the MCP Client SDK)</h3>
<pre><code class="language-typescript">import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("http://localhost:3100/mcp"),
  {
    requestInit: {
      headers: {
        Authorization: `Bearer ${userToken}`,
      },
    },
  }
);

const client = new Client(
  { name: "my-ai-app", version: "1.0.0" }
);

await client.connect(transport);

// Discover available tools
const { tools } = await client.listTools();
console.log("Available tools:", tools.map((t) =&gt; t.name));

// Call a tool
const result = await client.callTool({
  name: "search_employees",
  arguments: { query: "engineering manager" },
});

console.log(result.content);
</code></pre>
<p><code>StreamableHTTPClientTransport</code> manages the HTTP connection to your MCP server, including attaching the <code>Authorization</code> header to every request. <code>client.connect(transport)</code> performs the MCP initialization handshake — the client announces its capabilities and the server responds with the list of available tools and resources. <code>client.listTools()</code> returns the full tool catalog, which you can use to dynamically build a UI or pass directly to an LLM's tool-calling API. <code>client.callTool()</code> sends a JSON-RPC request to invoke a specific tool by name and returns the <code>content</code> array from the handler — the same format the AI model receives. In a production application, you'd pass this <code>content</code> back to the model as a tool result in the conversation history.</p>
<h2 id="heading-common-pitfalls">Common Pitfalls</h2>
<p><strong>1. Returning too much data.</strong> LLMs have context limits. If your database query returns 500 rows, don't send them all. Paginate, summarize, or limit results. 25 items is a reasonable default.</p>
<p><strong>2. Tool descriptions that are too generic.</strong> If you have <code>search_employees</code> and <code>search_contractors</code>, the AI needs to know the difference. Don't rely on the tool name alone — the description is what the model reads.</p>
<p><strong>3. Missing error handling.</strong> When a database query fails, return a structured error message, not a stack trace. The AI needs to tell the user something useful, and raw errors leak implementation details.</p>
<p><strong>4. No rate limiting.</strong> AI tool calls can happen in loops. If the model calls your tool 50 times in one conversation, you need circuit breakers:</p>
<pre><code class="language-typescript">const rateLimiter = new Map&lt;string, number[]&gt;();

function checkRateLimit(userId: string, limit = 30, windowMs = 60000) {
  const now = Date.now();
  const calls = rateLimiter.get(userId) ?? [];
  const recent = calls.filter((t) =&gt; now - t &lt; windowMs);

  if (recent.length &gt;= limit) {
    throw new Error(
      `Rate limit exceeded. Max ${limit} calls per minute.`
    );
  }

  recent.push(now);
  rateLimiter.set(userId, recent);
}
</code></pre>
<p><strong>5. Not testing with actual AI models.</strong> Your tools might look correct in unit tests but confuse the model. Test the full loop: AI model receives tool definitions, decides to call a tool, gets the result, and reasons about it. Adjust descriptions based on how the model actually behaves.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Building MCP servers for internal data is about three things:</p>
<ol>
<li><p><strong>Good tool design</strong> — clear descriptions, typed parameters, structured responses</p>
</li>
<li><p><strong>Proper access control</strong> — authenticate users, scope data access, log everything</p>
</li>
<li><p><strong>Production readiness</strong> — health checks, rate limiting, error handling, monitoring</p>
</li>
</ol>
<p>The protocol itself is straightforward. The hard work is designing the right abstractions over your internal systems so the AI can use them effectively without leaking data or overwhelming the context window.</p>
<p>Start with one or two high-value tools (employee lookup, document search), test them with real users, and expand from there. The best internal MCP servers grow organically based on what people actually ask the AI.</p>
<p>The full source code from this guide is available on <a href="https://github.com/mayur9210/build-mcp-server-template">GitHub</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI-Powered RAG Search Application with Next.js, Supabase, and OpenAI ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, you'll learn how to build a complete RAG (Retrieval-Augmented Generation) search application from scratch. Your application will allow users to upload documents, store them securely, and search through them using AI-powered semantic... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-powered-rag-search-application-with-nextjs-supabase-and-openai/</link>
                <guid isPermaLink="false">6978f421ead51482f82901bf</guid>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ supabase ]]>
                    </category>
                
                    <category>
                        <![CDATA[ openai ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mayur Vekariya ]]>
                </dc:creator>
                <pubDate>Tue, 27 Jan 2026 17:21:37 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769534479648/a3f19714-a00b-4444-9289-753902282ac6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, you'll learn how to build a complete RAG (Retrieval-Augmented Generation) search application from scratch. Your application will allow users to upload documents, store them securely, and search through them using AI-powered semantic search.</p>
<p>By the end of this guide, you'll have a fully functional application that can:</p>
<ul>
<li><p>Upload and process PDF, DOCX, and TXT files</p>
</li>
<li><p>Store documents in Supabase Storage</p>
</li>
<li><p>Generate embeddings using OpenAI</p>
</li>
<li><p>Perform semantic search across document chunks</p>
</li>
<li><p>Provide AI-generated answers based on document content</p>
</li>
<li><p>View and manage uploaded documents</p>
</li>
</ul>
<p>This is a production-ready solution that you can deploy and use immediately.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-understanding-the-technologies">Understanding the Technologies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-project-overview">Project Overview</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-create-your-nextjs-project">Step 1: Create Your Next.js Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-install-required-dependencies">Step 2: Install Required Dependencies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-set-up-your-supabase-project">Step 3: Set Up Your Supabase Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-configure-environment-variables">Step 4: Configure Environment Variables</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-create-the-upload-api-route">Step 5: Create the Upload API Route</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-6-create-the-rag-search-api-route">Step 6: Create the RAG Search API Route</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-7-create-the-documents-api-route">Step 7: Create the Documents API Route</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-8-create-the-upload-modal-component">Step 8: Create the Upload Modal Component</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-9-create-the-pdf-viewer-modal-component">Step 9: Create the PDF Viewer Modal Component</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-10-create-the-navigation-component">Step 10: Create the Navigation Component</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-11-create-the-home-page-search-interface">Step 11: Create the Home Page (Search Interface)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-12-create-the-documents-page">Step 12: Create the Documents Page</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-13-test-your-application">Step 13: Test Your Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-14-deploy-your-application">Step 14: Deploy Your Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-rag-search-works">How RAG Search Works</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-troubleshooting-common-issues">Troubleshooting Common Issues</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn"><strong>What You'll Learn</strong></h2>
<p>In this handbook, you'll learn how to:</p>
<ul>
<li><p>Set up a Next.js application with TypeScript</p>
</li>
<li><p>Configure Supabase for database and file storage</p>
</li>
<li><p>Integrate OpenAI embeddings and chat completions</p>
</li>
<li><p>Implement document text extraction and chunking</p>
</li>
<li><p>Build a vector search system using PostgreSQL</p>
</li>
<li><p>Create a modern UI with React components</p>
</li>
<li><p>Handle file uploads and storage</p>
</li>
<li><p>Implement RAG (Retrieval-Augmented Generation) search</p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you begin, make sure you have:</p>
<ul>
<li><p>Node.js 18 or higher installed on your computer</p>
</li>
<li><p>A Supabase account (free tier works fine)</p>
</li>
<li><p>An OpenAI API key</p>
</li>
<li><p>Basic knowledge of React and TypeScript</p>
</li>
<li><p>Familiarity with Next.js (helpful but not required)</p>
</li>
</ul>
<h2 id="heading-understanding-the-technologies"><strong>Understanding the Technologies</strong></h2>
<p>Before we dive into building the application, you should understand the key technologies and concepts you'll be working with:</p>
<h3 id="heading-what-is-rag-retrieval-augmented-generation"><strong>What is RAG (Retrieval-Augmented Generation)?</strong></h3>
<p>RAG is an AI pattern that combines information retrieval with text generation. Instead of relying solely on an AI model's training data, RAG retrieves relevant information from your own documents. It then uses that information as context to generate accurate, up-to-date answers. This approach gives you:</p>
<ul>
<li><p><strong>Accuracy</strong>: Answers are based on your actual documents, not just the AI's training data</p>
</li>
<li><p><strong>Transparency</strong>: You can see which document sections were used to generate the answer</p>
</li>
<li><p><strong>Efficiency</strong>: Only relevant document chunks are used, reducing token costs</p>
</li>
</ul>
<h3 id="heading-what-are-embeddings-and-vector-database"><strong>What are Embeddings and Vector Database?</strong></h3>
<p>Embeddings are numerical representations of text that capture semantic meaning. When you convert text to an embedding, similar meanings are represented by similar numbers. For example, "dog" and "puppy" would have similar embeddings. Meanwhile, "dog" and "airplane" would have very different ones.</p>
<p>OpenAI's embedding models convert text into vectors. These are arrays of numbers that can be compared mathematically. This allows you to find documents that are semantically similar to a search query. You can find matches even if they don't contain the exact same words.</p>
<p>A vector database is a specialized database designed to store and search through embeddings efficiently. Instead of searching for exact text matches, vector databases use mathematical operations. They use operations like <a target="_blank" href="https://www.freecodecamp.org/news/how-does-cosine-similarity-work/">cosine similarity</a> to find the most semantically similar content.</p>
<p>In this tutorial, you'll use Supabase's PostgreSQL database with the <code>pgvector</code> extension. This extension adds vector storage and similarity search capabilities to PostgreSQL. This lets you store embeddings alongside your regular database data. You can also perform fast similarity searches.</p>
<h3 id="heading-what-is-text-chunking"><strong>What is Text Chunking?</strong></h3>
<p>Text chunking is the process of breaking large documents into smaller, manageable pieces. This is necessary for several reasons.</p>
<p>First, AI models have token limits. These are maximum input sizes. Second, smaller chunks allow for more precise retrieval. Third, overlapping chunks ensure context isn't lost at boundaries.</p>
<p>You'll use LangChain's <code>RecursiveCharacterTextSplitter</code>. This tool intelligently splits text while trying to preserve sentence and paragraph boundaries.</p>
<h3 id="heading-what-is-supabase"><strong>What is Supabase?</strong></h3>
<p>Supabase is an open-source Firebase alternative. It provides several key features.</p>
<p>You get a PostgreSQL database, which is a powerful, open-source relational database. You also get storage, which is file storage similar to AWS S3. There are real-time features that provide real-time subscriptions to database changes. Finally, there's built-in user authentication.</p>
<p>For this project, you'll use Supabase's database to store document chunks and embeddings. You'll also use Supabase Storage to store the original uploaded files.</p>
<h3 id="heading-what-is-tailwind-css"><strong>What is Tailwind CSS?</strong></h3>
<p>Tailwind CSS is a utility-first CSS framework that lets you style your application by applying pre-built utility classes directly in your HTML/JSX. Instead of writing custom CSS, you use classes like <code>bg-blue-600</code>, <code>text-white</code>, and <code>rounded-lg</code> to style elements.</p>
<p>You'll use Tailwind CSS in this project because it speeds up development by providing ready-made styling utilities. It also ensures consistent design across the application. Plus, it makes it easy to create responsive, modern UIs. Finally, it works seamlessly with Next.js.</p>
<p>Now that you understand the core concepts and tools we’ll be using, let's start building the application.</p>
<h2 id="heading-project-overview"><strong>Project Overview</strong></h2>
<p>Your RAG search application will consist of:</p>
<ol>
<li><p><strong>Frontend</strong>: Next.js application with React components for uploading documents and searching</p>
</li>
<li><p><strong>Backend API Routes</strong>: Next.js API routes for handling uploads, searches, and document management</p>
</li>
<li><p><strong>Database</strong>: Supabase PostgreSQL with vector extension for storing embeddings</p>
</li>
<li><p><strong>Storage</strong>: Supabase Storage for storing original files</p>
</li>
<li><p><strong>AI Integration</strong>: OpenAI for generating embeddings and chat completions</p>
</li>
</ol>
<p>The application will have two main pages:</p>
<ul>
<li><p><strong>Search Page</strong>: Where users can ask questions about their uploaded documents and get AI-generated answers</p>
</li>
<li><p><strong>Documents Page</strong>: Where users can view all uploaded documents, upload new ones, preview files, and manage their document library</p>
</li>
</ul>
<p>Let's start building!</p>
<p>If you ever get stuck on the source code, you can view it on GitHub here:</p>
<p><a target="_blank" href="https://github.com/mayur9210/rag-search-app">https://github.com/mayur9210/rag-search-app</a></p>
<h2 id="heading-step-1-create-your-nextjs-project">Step 1: Create Your Next.js Project</h2>
<p>Start by creating a new Next.js project with TypeScript. Open your terminal and run:</p>
<pre><code class="lang-bash">npx create-next-app@latest rag-search-app --typescript --tailwind --app
</code></pre>
<p>When prompted, choose the following options:</p>
<ul>
<li><p>TypeScript: Yes</p>
</li>
<li><p>ESLint: Yes</p>
</li>
<li><p>Tailwind CSS: Yes</p>
</li>
<li><p>App Router: Yes (default)</p>
</li>
<li><p>Customize import alias: No</p>
</li>
</ul>
<p>Navigate into your project directory:</p>
<pre><code class="lang-bash"><span class="hljs-built_in">cd</span> rag-search-app
</code></pre>
<p>Now that your project is set up, you'll need to install the additional packages required for document processing, AI integration, and database operations.</p>
<h2 id="heading-step-2-install-required-dependencies">Step 2: Install Required Dependencies</h2>
<p>You'll need several packages for this project. You can install them using npm:</p>
<pre><code class="lang-bash">npm install @supabase/supabase-js @langchain/openai @langchain/textsplitters langchain openai mammoth pdf2json
</code></pre>
<p>Here's what each package does:</p>
<ul>
<li><p><code>@supabase/supabase-js</code>: Client library for interacting with Supabase (database and storage)</p>
</li>
<li><p><code>@langchain/openai</code>: LangChain integration for OpenAI (helps with text processing)</p>
</li>
<li><p><code>@langchain/textsplitters</code>: Text splitting utilities for chunking documents into smaller pieces</p>
</li>
<li><p><code>langchain</code>: Core LangChain library (provides AI workflow tools)</p>
</li>
<li><p><code>openai</code>: Official OpenAI SDK (for generating embeddings and chat completions)</p>
</li>
<li><p><code>mammoth</code>: Converts DOCX files to plain text</p>
</li>
<li><p><code>pdf2json</code>: Extracts text from PDF files</p>
</li>
</ul>
<p>Install the TypeScript types for pdf2json:</p>
<pre><code class="lang-bash">npm install --save-dev @types/pdf-parse
</code></pre>
<p>With all dependencies installed, you're ready to set up your Supabase project, which will handle your database and file storage needs.</p>
<h2 id="heading-step-3-set-up-your-supabase-project">Step 3: Set Up Your Supabase Project</h2>
<h3 id="heading-create-a-supabase-project">Create a Supabase Project</h3>
<p>First, you’ll need to create a new Supabase project, which you can do by following these steps:</p>
<ol>
<li><p>Go to <a target="_blank" href="https://supabase.com/"><strong>supabase.com</strong></a> and sign in or create an account</p>
</li>
<li><p>Click "New Project"</p>
</li>
<li><p>Fill in your project details:</p>
<ul>
<li><p>Name: <code>rag-search-app</code> (or any name you prefer)</p>
</li>
<li><p>Database Password: Choose a strong password (save this – you'll need it)</p>
</li>
<li><p>Region: Select the region closest to you</p>
</li>
</ul>
</li>
<li><p>Click "Create new project" and wait for it to be ready (this takes a few minutes)</p>
</li>
</ol>
<h3 id="heading-get-your-supabase-credentials">Get Your Supabase Credentials</h3>
<p>Once your project is ready, go to <strong>Settings</strong> and then <strong>API</strong>.</p>
<p>Copy the following values:</p>
<ul>
<li><p><strong>Project URL</strong> (this is your <code>NEXT_PUBLIC_SUPABASE_URL</code>)</p>
</li>
<li><p><strong>anon public key</strong> (this is your <code>NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code>)</p>
</li>
<li><p><strong>service_role key</strong> (this is your <code>SUPABASE_SERVICE_ROLE_KEY</code>)</p>
</li>
</ul>
<p><strong>Important</strong>: Keep your service role key secret. Never expose it in client-side code. It bypasses Row-Level Security (RLS) policies, which is necessary for server-side file uploads but should never be used in browser code.</p>
<h3 id="heading-set-up-the-database-schema"><strong>Set Up the Database Schema</strong></h3>
<p>Now you'll set up the database structure to store your documents and embeddings. Go to <strong>SQL Editor</strong> in your Supabase dashboard and run the following SQL:</p>
<pre><code class="lang-pgsql"><span class="hljs-comment">-- Enable the vector extension for embeddings</span>
<span class="hljs-comment">-- This extension allows PostgreSQL to store and search vector data efficiently</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">EXTENSION</span> <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> vector;

<span class="hljs-comment">-- Create the documents table</span>
<span class="hljs-comment">-- This table stores document chunks, their metadata, and embeddings</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> documents (
  id <span class="hljs-type">BIGSERIAL</span> <span class="hljs-keyword">PRIMARY KEY</span>,
  content <span class="hljs-type">TEXT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">NULL</span>,
  metadata <span class="hljs-type">JSONB</span>,
  embedding vector(<span class="hljs-number">1536</span>)  <span class="hljs-comment">-- OpenAI's text-embedding-3-small produces 1536-dimensional vectors</span>
  file_path <span class="hljs-type">text</span> <span class="hljs-keyword">null</span>,
  file_url <span class="hljs-type">text</span> <span class="hljs-keyword">null</span>,
);

<span class="hljs-comment">-- Create an index on the embedding column for faster similarity search</span>
<span class="hljs-comment">-- The ivfflat index speeds up vector similarity queries</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">INDEX</span> <span class="hljs-keyword">ON</span> documents <span class="hljs-keyword">USING</span> ivfflat (embedding vector_cosine_ops);

<span class="hljs-comment">-- Create a function for matching documents based on similarity</span>
<span class="hljs-comment">-- This function finds the most similar document chunks to a query embedding</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">OR REPLACE</span> <span class="hljs-keyword">FUNCTION</span> match_documents(
  query_embedding vector(<span class="hljs-number">1536</span>),
  match_threshold <span class="hljs-type">float</span>,
  match_count <span class="hljs-type">int</span>
)
<span class="hljs-keyword">RETURNS</span> <span class="hljs-keyword">TABLE</span> (
  id <span class="hljs-type">bigint</span>,
  content <span class="hljs-type">text</span>,
  metadata <span class="hljs-type">jsonb</span>,
  similarity <span class="hljs-type">float</span>
)
<span class="hljs-keyword">LANGUAGE</span> plpgsql
<span class="hljs-keyword">AS</span> $$<span class="pgsql">
<span class="hljs-keyword">BEGIN</span>
  <span class="hljs-keyword">RETURN QUERY</span>
  <span class="hljs-keyword">SELECT</span>
    documents.id,
    documents.content,
    documents.metadata,
    <span class="hljs-number">1</span> - (documents.embedding &lt;=&gt; query_embedding) <span class="hljs-keyword">AS</span> similarity
  <span class="hljs-keyword">FROM</span> documents
  <span class="hljs-keyword">WHERE</span> <span class="hljs-number">1</span> - (documents.embedding &lt;=&gt; query_embedding) &gt; match_threshold
  <span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> documents.embedding &lt;=&gt; query_embedding
  <span class="hljs-keyword">LIMIT</span> match_count;
<span class="hljs-keyword">END</span>;
$$</span>;
</code></pre>
<p>This SQL does the following:</p>
<ul>
<li><p><strong>Enables the vector extension</strong>: This adds vector storage and similarity search capabilities to PostgreSQL</p>
</li>
<li><p><strong>Creates the documents table</strong>: Stores document chunks, metadata (file name, type, and so on), and their embeddings</p>
</li>
<li><p><strong>Creates an index</strong>: Speeds up similarity searches on the embedding column</p>
</li>
<li><p><strong>Creates a match function</strong>: Finds the most similar document chunks to a query embedding using cosine similarity</p>
</li>
</ul>
<p>The <code>&lt;=&gt;</code> operator calculates cosine distance between vectors. A smaller distance means more similar content.</p>
<h3 id="heading-set-up-supabase-storage"><strong>Set Up Supabase Storage</strong></h3>
<p>You’ll need a storage bucket to store uploaded files. This is separate from the database and holds the original PDF, DOCX, and TXT files.</p>
<p>To set up your storage bucket:</p>
<ol>
<li><p>Go to <strong>Storage</strong> in your Supabase dashboard</p>
</li>
<li><p>Click <strong>New bucket</strong></p>
</li>
<li><p>Name it <code>documents</code></p>
</li>
<li><p>Set it to <strong>Public</strong> (this allows file downloads)</p>
</li>
<li><p>Click <strong>Create bucket</strong></p>
</li>
</ol>
<p>If you prefer a private bucket, you can use the service role key for server-side operations, which bypasses Row-Level Security policies. For this tutorial, a public bucket is simpler and works well.</p>
<p>Now that your Supabase project is configured, you'll set up your environment variables to connect your Next.js application to Supabase and OpenAI.</p>
<h2 id="heading-step-4-configure-environment-variables">Step 4: Configure Environment Variables</h2>
<p>Create a <code>.env.local</code> file in your project root:</p>
<pre><code class="lang-bash">NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
OPENAI_API_KEY=your_openai_api_key
</code></pre>
<p>Replace the placeholder values with your actual credentials:</p>
<ul>
<li><p>Get Supabase values from <strong>Settings</strong> → <strong>API</strong> in your Supabase dashboard</p>
</li>
<li><p>Get your OpenAI API key from <a target="_blank" href="https://platform.openai.com/api-keys"><strong>platform.openai.com/api-keys</strong></a></p>
</li>
</ul>
<p><strong>Security Note</strong>: Never commit <code>.env.local</code> to version control. It's already in <code>.gitignore</code> by default, but double-check to ensure your secrets stay secure.</p>
<p>With your environment configured, you're ready to start building the API routes that will handle file uploads, searches, and document management.</p>
<h2 id="heading-step-5-create-the-upload-api-route">Step 5: Create the Upload API Route</h2>
<p>Now you'll create the API route that handles file uploads. This route will process uploaded files, extract their text, split them into chunks, generate embeddings, and store everything in your database and storage.</p>
<p>Create <code>src/app/api/upload/route.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@supabase/supabase-js'</span>;
<span class="hljs-keyword">import</span> OpenAI <span class="hljs-keyword">from</span> <span class="hljs-string">'openai'</span>;
<span class="hljs-keyword">import</span> { NextResponse } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/server'</span>;
<span class="hljs-keyword">import</span> { RecursiveCharacterTextSplitter } <span class="hljs-keyword">from</span> <span class="hljs-string">'@langchain/textsplitters'</span>;
<span class="hljs-keyword">import</span> mammoth <span class="hljs-keyword">from</span> <span class="hljs-string">'mammoth'</span>;

<span class="hljs-keyword">const</span> url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
<span class="hljs-keyword">const</span> anonKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!;
<span class="hljs-keyword">const</span> serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
<span class="hljs-keyword">const</span> supabaseStorage = createClient(url, serviceKey || anonKey);
<span class="hljs-keyword">const</span> supabase = createClient(url, anonKey);
<span class="hljs-keyword">const</span> openai = <span class="hljs-keyword">new</span> OpenAI();

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">safeDecodeURIComponent</span>(<span class="hljs-params">str: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">string</span> </span>{
  <span class="hljs-keyword">try</span> { 
    <span class="hljs-keyword">return</span> <span class="hljs-built_in">decodeURIComponent</span>(str); 
  } <span class="hljs-keyword">catch</span> { 
    <span class="hljs-keyword">try</span> { 
      <span class="hljs-keyword">return</span> <span class="hljs-built_in">decodeURIComponent</span>(str.replace(<span class="hljs-regexp">/%/g</span>, <span class="hljs-string">'%25'</span>)); 
    } <span class="hljs-keyword">catch</span> { 
      <span class="hljs-keyword">return</span> str; 
    } 
  }
}

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">extractTextFromFile</span>(<span class="hljs-params">file: File</span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">string</span>&gt; </span>{
  <span class="hljs-keyword">const</span> buffer = Buffer.from(<span class="hljs-keyword">await</span> file.arrayBuffer());
  <span class="hljs-keyword">const</span> fileName = file.name.toLowerCase();

  <span class="hljs-keyword">if</span> (fileName.endsWith(<span class="hljs-string">'.pdf'</span>)) {
    <span class="hljs-keyword">const</span> PDFParser = (<span class="hljs-keyword">await</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'pdf2json'</span>)).default;
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Promise</span>(<span class="hljs-function">(<span class="hljs-params">resolve, reject</span>) =&gt;</span> {
      <span class="hljs-keyword">const</span> pdfParser = <span class="hljs-keyword">new</span> (PDFParser <span class="hljs-keyword">as</span> <span class="hljs-built_in">any</span>)(<span class="hljs-literal">null</span>, <span class="hljs-literal">true</span>);
      pdfParser.on(<span class="hljs-string">'pdfParser_dataError'</span>, <span class="hljs-function">(<span class="hljs-params">err: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
        reject(<span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">`PDF parsing error: <span class="hljs-subst">${err.parserError}</span>`</span>))
      );
      pdfParser.on(<span class="hljs-string">'pdfParser_dataReady'</span>, <span class="hljs-function">(<span class="hljs-params">pdfData: <span class="hljs-built_in">any</span></span>) =&gt;</span> {
        <span class="hljs-keyword">try</span> {
          <span class="hljs-keyword">let</span> fullText = <span class="hljs-string">''</span>;
          pdfData.Pages?.forEach(<span class="hljs-function">(<span class="hljs-params">page: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
            page.Texts?.forEach(<span class="hljs-function">(<span class="hljs-params">text: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
              text.R?.forEach(<span class="hljs-function">(<span class="hljs-params">r: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
                r.T &amp;&amp; (fullText += safeDecodeURIComponent(r.T) + <span class="hljs-string">' '</span>)
              )
            )
          );
          resolve(fullText.trim());
        } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
          reject(<span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">`Error extracting text: <span class="hljs-subst">${error.message}</span>`</span>));
        }
      });
      pdfParser.parseBuffer(buffer);
    });
  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (fileName.endsWith(<span class="hljs-string">'.docx'</span>)) {
    <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> mammoth.extractRawText({ buffer });
    <span class="hljs-keyword">return</span> result.value;
  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (fileName.endsWith(<span class="hljs-string">'.txt'</span>)) {
    <span class="hljs-keyword">return</span> buffer.toString(<span class="hljs-string">'utf-8'</span>);
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'Unsupported file type. Please upload PDF, DOCX, or TXT files.'</span>);
  }
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">POST</span>(<span class="hljs-params">req: Request</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> file = (<span class="hljs-keyword">await</span> req.formData()).get(<span class="hljs-string">'file'</span>) <span class="hljs-keyword">as</span> File;
    <span class="hljs-keyword">if</span> (!file) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'No file provided'</span> }, { status: <span class="hljs-number">400</span> });
    }

    <span class="hljs-keyword">const</span> documentId = crypto.randomUUID();
    <span class="hljs-keyword">const</span> uploadDate = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString();
    <span class="hljs-keyword">const</span> filePath = <span class="hljs-string">`<span class="hljs-subst">${documentId}</span>.<span class="hljs-subst">${file.name.split(<span class="hljs-string">'.'</span>).pop() || <span class="hljs-string">'bin'</span>}</span>`</span>;

    <span class="hljs-comment">// Upload file to Supabase Storage</span>
    <span class="hljs-keyword">const</span> fileBuffer = Buffer.from(<span class="hljs-keyword">await</span> file.arrayBuffer());
    <span class="hljs-keyword">const</span> { error: storageError } = <span class="hljs-keyword">await</span> supabaseStorage.storage
      .from(<span class="hljs-string">'documents'</span>)
      .upload(filePath, fileBuffer, {
        contentType: file.type || <span class="hljs-string">'application/octet-stream'</span>,
        upsert: <span class="hljs-literal">false</span>,
      });

    <span class="hljs-keyword">if</span> (storageError) {
      <span class="hljs-keyword">const</span> msg = storageError.message || <span class="hljs-string">'Unknown storage error'</span>;
      <span class="hljs-keyword">if</span> (msg.includes(<span class="hljs-string">'row-level security'</span>) || msg.includes(<span class="hljs-string">'RLS'</span>)) {
        <span class="hljs-keyword">return</span> NextResponse.json({ 
          success: <span class="hljs-literal">false</span>, 
          error: <span class="hljs-string">`Storage RLS error: <span class="hljs-subst">${msg}</span>. Ensure SUPABASE_SERVICE_ROLE_KEY is set.`</span> 
        }, { status: <span class="hljs-number">500</span> });
      }
      <span class="hljs-keyword">return</span> NextResponse.json({ 
        success: <span class="hljs-literal">false</span>, 
        error: <span class="hljs-string">`Failed to store file: <span class="hljs-subst">${msg}</span>`</span> 
      }, { status: <span class="hljs-number">500</span> });
    }

    <span class="hljs-comment">// Get public URL for the file</span>
    <span class="hljs-keyword">const</span> { data: urlData } = supabaseStorage.storage
      .from(<span class="hljs-string">'documents'</span>)
      .getPublicUrl(filePath);

    <span class="hljs-comment">// Extract text from file</span>
    <span class="hljs-keyword">const</span> text = <span class="hljs-keyword">await</span> extractTextFromFile(file);
    <span class="hljs-keyword">if</span> (!text || text.trim().length === <span class="hljs-number">0</span>) {
      <span class="hljs-keyword">return</span> NextResponse.json({ 
        error: <span class="hljs-string">'Could not extract text from file'</span> 
      }, { status: <span class="hljs-number">400</span> });
    }

    <span class="hljs-comment">// Split text into chunks</span>
    <span class="hljs-comment">// Chunk size of 800 characters with 100-character overlap ensures</span>
    <span class="hljs-comment">// we don't lose context at chunk boundaries</span>
    <span class="hljs-keyword">const</span> textSplitter = <span class="hljs-keyword">new</span> RecursiveCharacterTextSplitter({
      chunkSize: <span class="hljs-number">800</span>,
      chunkOverlap: <span class="hljs-number">100</span>,
    });
    <span class="hljs-keyword">const</span> chunks = <span class="hljs-keyword">await</span> textSplitter.splitText(text);

    <span class="hljs-comment">// Process each chunk: generate embedding and store in database</span>
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; chunks.length; i++) {
      <span class="hljs-keyword">const</span> chunk = chunks[i];

      <span class="hljs-comment">// Generate embedding using OpenAI</span>
      <span class="hljs-comment">// This converts the text chunk into a 1536-dimensional vector</span>
      <span class="hljs-keyword">const</span> emb = <span class="hljs-keyword">await</span> openai.embeddings.create({
        model: <span class="hljs-string">'text-embedding-3-small'</span>,
        input: chunk,
      });

      <span class="hljs-comment">// Store chunk with embedding in database</span>
      <span class="hljs-keyword">const</span> { error } = <span class="hljs-keyword">await</span> supabase.from(<span class="hljs-string">'documents'</span>).insert({
        content: chunk,
        metadata: { 
          source: file.name,
          document_id: documentId,
          file_name: file.name,
          file_type: file.type || file.name.split(<span class="hljs-string">'.'</span>).pop(),
          file_size: file.size,
          upload_date: uploadDate,
          chunk_index: i,
          total_chunks: chunks.length,
          file_path: filePath,
          file_url: urlData.publicUrl,
        },
        embedding: <span class="hljs-built_in">JSON</span>.stringify(emb.data[<span class="hljs-number">0</span>].embedding),
      });

      <span class="hljs-keyword">if</span> (error) {
        <span class="hljs-keyword">return</span> NextResponse.json({ 
          success: <span class="hljs-literal">false</span>, 
          error: error.message 
        }, { status: <span class="hljs-number">500</span> });
      }
    }

    <span class="hljs-keyword">return</span> NextResponse.json({ 
      success: <span class="hljs-literal">true</span>, 
      documentId, 
      fileName: file.name, 
      chunks: chunks.length, 
      textLength: text.length, 
      fileUrl: urlData.publicUrl 
    });
  } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
    <span class="hljs-keyword">return</span> NextResponse.json({ 
      success: <span class="hljs-literal">false</span>, 
      error: error.message || <span class="hljs-string">'Failed to process file'</span> 
    }, { status: <span class="hljs-number">500</span> });
  }
}
</code></pre>
<p>This route handles the complete upload workflow:</p>
<ol>
<li><p>Receives the file from the client via FormData</p>
</li>
<li><p>Generates a unique document ID using <code>crypto.randomUUID()</code></p>
</li>
<li><p>Uploads the file to Supabase Storage for safekeeping</p>
</li>
<li><p>Extracts text based on file type (PDF, DOCX, or TXT)</p>
</li>
<li><p>Splits the text into chunks of 800 characters with 100-character overlap</p>
</li>
<li><p>Generates embeddings for each chunk using OpenAI's embedding model</p>
</li>
<li><p>Stores each chunk with its embedding and metadata in the database</p>
</li>
</ol>
<p>The overlap between chunks ensures that if a sentence or concept spans a chunk boundary, it won't be lost. Now that you can upload and process documents, let's create the search functionality.</p>
<h2 id="heading-step-6-create-the-rag-search-api-route">Step 6: Create the RAG Search API Route</h2>
<p>This route implements the core RAG functionality: it takes a user's query, finds the most relevant document chunks, and uses them to generate an accurate answer.</p>
<p>Create <code>src/app/api/search/route.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@supabase/supabase-js'</span>;
<span class="hljs-keyword">import</span> OpenAI <span class="hljs-keyword">from</span> <span class="hljs-string">'openai'</span>;
<span class="hljs-keyword">import</span> { NextResponse } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/server'</span>;

<span class="hljs-keyword">const</span> supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!
);
<span class="hljs-keyword">const</span> openai = <span class="hljs-keyword">new</span> OpenAI();

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">POST</span>(<span class="hljs-params">req: Request</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> { query } = <span class="hljs-keyword">await</span> req.json();

    <span class="hljs-comment">// Generate embedding for the user's query</span>
    <span class="hljs-comment">// This converts the search query into the same vector space as document chunks</span>
    <span class="hljs-keyword">const</span> emb = <span class="hljs-keyword">await</span> openai.embeddings.create({ 
      model: <span class="hljs-string">'text-embedding-3-small'</span>, 
      input: query 
    });

    <span class="hljs-comment">// Find similar documents using vector similarity search</span>
    <span class="hljs-comment">// The match_documents function finds the 5 most similar chunks</span>
    <span class="hljs-keyword">const</span> { data: results, error } = <span class="hljs-keyword">await</span> supabase.rpc(<span class="hljs-string">'match_documents'</span>, {
      query_embedding: <span class="hljs-built_in">JSON</span>.stringify(emb.data[<span class="hljs-number">0</span>].embedding),
      match_threshold: <span class="hljs-number">0.0</span>,  <span class="hljs-comment">// Accept any similarity (you can increase this for stricter matching)</span>
      match_count: <span class="hljs-number">5</span>,        <span class="hljs-comment">// Return top 5 most similar chunks</span>
    });

    <span class="hljs-keyword">if</span> (error) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
    }

    <span class="hljs-comment">// Combine retrieved chunks into context</span>
    <span class="hljs-comment">// These chunks will be used as context for the AI to generate an answer</span>
    <span class="hljs-keyword">const</span> context = results?.map(<span class="hljs-function">(<span class="hljs-params">r: <span class="hljs-built_in">any</span></span>) =&gt;</span> r.content).join(<span class="hljs-string">'\n---\n'</span>) || <span class="hljs-string">''</span>;

    <span class="hljs-comment">// Generate answer using OpenAI with retrieved context</span>
    <span class="hljs-comment">// This is the "Generation" part of RAG</span>
    <span class="hljs-keyword">const</span> completion = <span class="hljs-keyword">await</span> openai.chat.completions.create({
      model: <span class="hljs-string">'gpt-4o-mini'</span>,
      messages: [
        { 
          role: <span class="hljs-string">'system'</span>, 
          content: <span class="hljs-string">'You are a helpful assistant. Use the provided context to answer questions. If the answer is not in the context, say you do not know.'</span> 
        },
        { 
          role: <span class="hljs-string">'user'</span>, 
          content: <span class="hljs-string">`Context: <span class="hljs-subst">${context}</span>\n\nQuestion: <span class="hljs-subst">${query}</span>`</span> 
        }
      ],
    });

    <span class="hljs-keyword">return</span> NextResponse.json({ 
      answer: completion.choices[<span class="hljs-number">0</span>].message.content, 
      sources: results 
    });
  } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
    <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
  }
}
</code></pre>
<p>This route implements the RAG pattern. Here's how the complete RAG workflow works:</p>
<ol>
<li><p><strong>Converts the query to an embedding</strong>: The user's question is transformed into the same vector space as your document chunks. This uses the same embedding model (<code>text-embedding-3-small</code>) that processed the documents, ensuring they're in the same "vector space."</p>
</li>
<li><p><strong>Searches for similar chunks</strong>: Uses the <code>match_documents</code> function to find the 5 most semantically similar document chunks. This uses cosine similarity on the embeddings. Cosine similarity measures the angle between vectors - smaller angles mean more similar content, even if the exact words differ.</p>
</li>
<li><p><strong>Uses chunks as context</strong>: The retrieved chunks are passed to GPT-4o-mini as context. These chunks contain the most relevant information from your documents.</p>
</li>
<li><p><strong>Generates an answer</strong>: The AI model generates an answer based on the provided context. The system prompt instructs the AI to only answer based on the provided context, ensuring accuracy and preventing hallucinations.</p>
</li>
<li><p><strong>Returns results</strong>: Both the answer and source chunks are returned so users can verify the information.</p>
</li>
</ol>
<p>This RAG approach gives you several benefits. First, you get accuracy because answers are based on your actual documents, not just the AI's training data. Second, you get transparency because you can see which document chunks were used to generate each answer. Third, you get efficiency because only relevant chunks are used, which reduces token usage and costs. Finally, you get up-to-date information because you can update your knowledge base by uploading new documents without retraining the AI.</p>
<p>Now let's create the API route for managing documents.</p>
<h2 id="heading-step-7-create-the-documents-api-route">Step 7: Create the Documents API Route</h2>
<p>This route handles listing, viewing, downloading, and deleting documents. It serves multiple purposes depending on the query parameters.</p>
<p>Create <code>src/app/api/documents/route.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@supabase/supabase-js'</span>;
<span class="hljs-keyword">import</span> { NextResponse } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/server'</span>;

<span class="hljs-keyword">const</span> url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
<span class="hljs-keyword">const</span> anonKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!;
<span class="hljs-keyword">const</span> serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || anonKey;
<span class="hljs-keyword">const</span> supabase = createClient(url, anonKey);
<span class="hljs-keyword">const</span> supabaseStorage = createClient(url, serviceKey);

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">GET</span>(<span class="hljs-params">req: Request</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> reqUrl = <span class="hljs-keyword">new</span> URL(req.url);
    <span class="hljs-keyword">const</span> id = reqUrl.searchParams.get(<span class="hljs-string">'id'</span>);
    <span class="hljs-keyword">const</span> file = reqUrl.searchParams.get(<span class="hljs-string">'file'</span>) === <span class="hljs-string">'true'</span>;
    <span class="hljs-keyword">const</span> view = reqUrl.searchParams.get(<span class="hljs-string">'view'</span>) === <span class="hljs-string">'true'</span>;

    <span class="hljs-comment">// Handle file download/view</span>
    <span class="hljs-keyword">if</span> (id &amp;&amp; file) {
      <span class="hljs-keyword">const</span> { data: documents } = <span class="hljs-keyword">await</span> supabase
        .from(<span class="hljs-string">'documents'</span>)
        .select(<span class="hljs-string">'metadata'</span>)
        .eq(<span class="hljs-string">'metadata-&gt;&gt;document_id'</span>, id)
        .limit(<span class="hljs-number">1</span>);

      <span class="hljs-keyword">if</span> (!documents || documents.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'Document not found'</span> }, { status: <span class="hljs-number">404</span> });
      }

      <span class="hljs-keyword">const</span> meta = documents[<span class="hljs-number">0</span>].metadata;
      <span class="hljs-keyword">const</span> fileName = meta?.file_name || <span class="hljs-string">'document'</span>;
      <span class="hljs-keyword">const</span> fileType = meta?.file_type || <span class="hljs-string">'application/octet-stream'</span>;
      <span class="hljs-keyword">const</span> filePath = meta?.file_path || <span class="hljs-string">`<span class="hljs-subst">${id}</span>.<span class="hljs-subst">${fileName.split(<span class="hljs-string">'.'</span>).pop() || <span class="hljs-string">'pdf'</span>}</span>`</span>;

      <span class="hljs-keyword">const</span> { data: fileData, error: downloadError } = <span class="hljs-keyword">await</span> supabaseStorage.storage
        .from(<span class="hljs-string">'documents'</span>)
        .download(filePath);

      <span class="hljs-keyword">if</span> (downloadError || !fileData) {
        <span class="hljs-keyword">return</span> NextResponse.json({ 
          error: downloadError?.message || <span class="hljs-string">'File not stored'</span> 
        }, { status: <span class="hljs-number">404</span> });
      }

      <span class="hljs-keyword">const</span> buffer = Buffer.from(<span class="hljs-keyword">await</span> fileData.arrayBuffer());
      <span class="hljs-keyword">if</span> (buffer.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'File is empty'</span> }, { status: <span class="hljs-number">500</span> });
      }

      <span class="hljs-keyword">const</span> isPDF = fileType === <span class="hljs-string">'application/pdf'</span> || fileName.toLowerCase().endsWith(<span class="hljs-string">'.pdf'</span>);
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> NextResponse(<span class="hljs-keyword">new</span> <span class="hljs-built_in">Uint8Array</span>(buffer), {
        headers: {
          <span class="hljs-string">'Content-Type'</span>: fileType,
          <span class="hljs-string">'Content-Disposition'</span>: (view &amp;&amp; isPDF) 
            ? <span class="hljs-string">`inline; filename="<span class="hljs-subst">${fileName}</span>"`</span> 
            : <span class="hljs-string">`attachment; filename="<span class="hljs-subst">${fileName}</span>"`</span>,
          <span class="hljs-string">'Content-Length'</span>: buffer.length.toString(),
          ...(view &amp;&amp; isPDF ? { <span class="hljs-string">'X-Content-Type-Options'</span>: <span class="hljs-string">'nosniff'</span> } : {}),
        },
      });
    }

    <span class="hljs-comment">// Get single document with text content</span>
    <span class="hljs-keyword">if</span> (id) {
      <span class="hljs-keyword">const</span> { data: chunks, error } = <span class="hljs-keyword">await</span> supabase
        .from(<span class="hljs-string">'documents'</span>)
        .select(<span class="hljs-string">'content, metadata'</span>)
        .eq(<span class="hljs-string">'metadata-&gt;&gt;document_id'</span>, id)
        .order(<span class="hljs-string">'metadata-&gt;&gt;chunk_index'</span>, { ascending: <span class="hljs-literal">true</span> });

      <span class="hljs-keyword">if</span> (error || !chunks || chunks.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'Document not found'</span> }, { status: <span class="hljs-number">404</span> });
      }

      <span class="hljs-keyword">const</span> m = chunks[<span class="hljs-number">0</span>].metadata || {};
      <span class="hljs-keyword">return</span> NextResponse.json({
        id,
        file_name: m.file_name || <span class="hljs-string">'Unknown'</span>,
        file_type: m.file_type || <span class="hljs-string">'unknown'</span>,
        file_size: m.file_size || <span class="hljs-number">0</span>,
        upload_date: m.upload_date || <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString(),
        total_chunks: chunks.length,
        fullText: chunks.map(<span class="hljs-function">(<span class="hljs-params">c: <span class="hljs-built_in">any</span></span>) =&gt;</span> c.content).join(<span class="hljs-string">'\n\n'</span>),
        file_url: m.file_url,
        file_path: m.file_path
      });
    }

    <span class="hljs-comment">// List all documents</span>
    <span class="hljs-keyword">const</span> { data: documents, error } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'documents'</span>)
      .select(<span class="hljs-string">'metadata'</span>);

    <span class="hljs-keyword">if</span> (error) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
    }

    <span class="hljs-comment">// Deduplicate documents by document_id</span>
    <span class="hljs-comment">// Since each document is split into multiple chunks, we need to group them</span>
    <span class="hljs-keyword">const</span> map = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>();
    documents?.forEach(<span class="hljs-function">(<span class="hljs-params">doc: <span class="hljs-built_in">any</span></span>) =&gt;</span> {
      <span class="hljs-keyword">const</span> m = doc.metadata;
      <span class="hljs-keyword">if</span> (m?.document_id &amp;&amp; !map.has(m.document_id)) {
        map.set(m.document_id, {
          id: m.document_id,
          file_name: m.file_name || <span class="hljs-string">'Unknown'</span>,
          file_type: m.file_type || <span class="hljs-string">'unknown'</span>,
          file_size: m.file_size || <span class="hljs-number">0</span>,
          upload_date: m.upload_date || <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString(),
          total_chunks: m.total_chunks || <span class="hljs-number">0</span>,
          file_url: m.file_url,
          file_path: m.file_path,
        });
      }
    });

    <span class="hljs-keyword">return</span> NextResponse.json({ documents: <span class="hljs-built_in">Array</span>.from(map.values()) });
  } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
    <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
  }
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">DELETE</span>(<span class="hljs-params">req: Request</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> id = <span class="hljs-keyword">new</span> URL(req.url).searchParams.get(<span class="hljs-string">'id'</span>);
    <span class="hljs-keyword">if</span> (!id) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'Document ID required'</span> }, { status: <span class="hljs-number">400</span> });
    }

    <span class="hljs-comment">// Get file path from metadata</span>
    <span class="hljs-keyword">const</span> { data: docs } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'documents'</span>)
      .select(<span class="hljs-string">'metadata'</span>)
      .eq(<span class="hljs-string">'metadata-&gt;&gt;document_id'</span>, id)
      .limit(<span class="hljs-number">1</span>);

    <span class="hljs-keyword">const</span> filePath = docs?.[<span class="hljs-number">0</span>]?.metadata?.file_path;

    <span class="hljs-comment">// Delete file from storage</span>
    <span class="hljs-keyword">if</span> (filePath) {
      <span class="hljs-keyword">await</span> supabaseStorage.storage.from(<span class="hljs-string">'documents'</span>).remove([filePath]);
    }

    <span class="hljs-comment">// Delete all chunks from database</span>
    <span class="hljs-keyword">const</span> { error } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'documents'</span>)
      .delete()
      .eq(<span class="hljs-string">'metadata-&gt;&gt;document_id'</span>, id);

    <span class="hljs-keyword">if</span> (error) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
    }

    <span class="hljs-keyword">return</span> NextResponse.json({ success: <span class="hljs-literal">true</span>, fileDeleted: !!filePath });
  } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
    <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
  }
}
</code></pre>
<p>This route handles:</p>
<ul>
<li><p><strong>GET without ID</strong>: Lists all documents (deduplicated since each document has multiple chunks)</p>
</li>
<li><p><strong>GET with ID</strong>: Returns document details and full text (all chunks combined)</p>
</li>
<li><p><strong>GET with ID and file=true</strong>: Downloads the original file from storage</p>
</li>
<li><p><strong>DELETE with ID</strong>: Deletes the document and its file from both storage and database</p>
</li>
</ul>
<p>Now that your API routes are complete, let's build the user interface components, starting with the upload modal.</p>
<h2 id="heading-step-8-create-the-upload-modal-component">Step 8: Create the Upload Modal Component</h2>
<p>The upload modal provides a user-friendly interface for selecting and uploading documents. It handles file selection, upload progress, and displays success or error messages.</p>
<p>Create <code>src/app/components/UploadModal.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;
<span class="hljs-keyword">import</span> { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;

<span class="hljs-keyword">interface</span> UploadModalProps {
  isOpen: <span class="hljs-built_in">boolean</span>;
  onClose: <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</span>;
  onUploadSuccess?: <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">UploadModal</span>(<span class="hljs-params">{ isOpen, onClose, onUploadSuccess }: UploadModalProps</span>) </span>{
  <span class="hljs-keyword">const</span> [file, setFile] = useState&lt;File | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [uploading, setUploading] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [message, setMessage] = useState&lt;{ <span class="hljs-keyword">type</span>: <span class="hljs-string">'success'</span> | <span class="hljs-string">'error'</span>; text: <span class="hljs-built_in">string</span> } | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-built_in">document</span>.body.style.overflow = isOpen ? <span class="hljs-string">'hidden'</span> : <span class="hljs-string">'unset'</span>;
    <span class="hljs-keyword">if</span> (!isOpen) { 
      setFile(<span class="hljs-literal">null</span>); 
      setMessage(<span class="hljs-literal">null</span>); 
    }
    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> { 
      <span class="hljs-built_in">document</span>.body.style.overflow = <span class="hljs-string">'unset'</span>; 
    };
  }, [isOpen]);

  <span class="hljs-keyword">const</span> handleFileChange = <span class="hljs-function">(<span class="hljs-params">e: React.ChangeEvent&lt;HTMLInputElement&gt;</span>) =&gt;</span> {
    <span class="hljs-keyword">if</span> (e.target.files &amp;&amp; e.target.files[<span class="hljs-number">0</span>]) {
      setFile(e.target.files[<span class="hljs-number">0</span>]);
      setMessage(<span class="hljs-literal">null</span>);
    }
  };

  <span class="hljs-keyword">const</span> handleUpload = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">if</span> (!file) {
      setMessage({ <span class="hljs-keyword">type</span>: <span class="hljs-string">'error'</span>, text: <span class="hljs-string">'Please select a file'</span> });
      <span class="hljs-keyword">return</span>;
    }

    setUploading(<span class="hljs-literal">true</span>);
    setMessage(<span class="hljs-literal">null</span>);

    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> formData = <span class="hljs-keyword">new</span> FormData();
      formData.append(<span class="hljs-string">'file'</span>, file);

      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">'/api/upload'</span>, {
        method: <span class="hljs-string">'POST'</span>,
        body: formData,
      });

      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();

      <span class="hljs-keyword">if</span> (data.success) {
        setMessage({
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'success'</span>,
          text: <span class="hljs-string">`File "<span class="hljs-subst">${data.fileName}</span>" uploaded successfully! Processed <span class="hljs-subst">${data.chunks}</span> chunks.`</span>,
        });
        setFile(<span class="hljs-literal">null</span>);
        (<span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'upload-file-input'</span>) <span class="hljs-keyword">as</span> HTMLInputElement)?.setAttribute(<span class="hljs-string">'value'</span>, <span class="hljs-string">''</span>);
        <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> { 
          onUploadSuccess?.(); 
          onClose(); 
        }, <span class="hljs-number">1500</span>);
      } <span class="hljs-keyword">else</span> {
        setMessage({ <span class="hljs-keyword">type</span>: <span class="hljs-string">'error'</span>, text: data.error || <span class="hljs-string">'Upload failed'</span> });
      }
    } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
      setMessage({ <span class="hljs-keyword">type</span>: <span class="hljs-string">'error'</span>, text: error.message || <span class="hljs-string">'Upload failed'</span> });
    } <span class="hljs-keyword">finally</span> {
      setUploading(<span class="hljs-literal">false</span>);
    }
  };

  <span class="hljs-keyword">if</span> (!isOpen) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;

  <span class="hljs-keyword">return</span> (
    &lt;div
      className=<span class="hljs-string">"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 p-4"</span>
      onClick={onClose}
    &gt;
      &lt;div
        className=<span class="hljs-string">"relative bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"</span>
        onClick={<span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> e.stopPropagation()}
      &gt;
        &lt;div className=<span class="hljs-string">"flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800"</span>&gt;
          &lt;h2 className=<span class="hljs-string">"text-2xl font-semibold text-gray-900 dark:text-gray-100"</span>&gt;
            Upload Document
          &lt;/h2&gt;
          &lt;button
            onClick={onClose}
            className=<span class="hljs-string">"p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"</span>
            aria-label=<span class="hljs-string">"Close"</span>
          &gt;
            &lt;svg className=<span class="hljs-string">"w-6 h-6"</span> fill=<span class="hljs-string">"none"</span> stroke=<span class="hljs-string">"currentColor"</span> viewBox=<span class="hljs-string">"0 0 24 24"</span>&gt;
              &lt;path strokeLinecap=<span class="hljs-string">"round"</span> strokeLinejoin=<span class="hljs-string">"round"</span> strokeWidth={<span class="hljs-number">2</span>} d=<span class="hljs-string">"M6 18L18 6M6 6l12 12"</span> /&gt;
            &lt;/svg&gt;
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div className=<span class="hljs-string">"p-6"</span>&gt;
          &lt;div className=<span class="hljs-string">"mb-6"</span>&gt;
            &lt;label htmlFor=<span class="hljs-string">"upload-file-input"</span> className=<span class="hljs-string">"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"</span>&gt;
              Select a file (PDF, DOCX, or TXT)
            &lt;/label&gt;
            &lt;input
              id=<span class="hljs-string">"upload-file-input"</span>
              <span class="hljs-keyword">type</span>=<span class="hljs-string">"file"</span>
              accept=<span class="hljs-string">".pdf,.docx,.txt"</span>
              onChange={handleFileChange}
              className=<span class="hljs-string">"block w-full text-sm text-gray-500
                file:mr-4 file:py-2 file:px-4
                file:rounded-lg file:border-0
                file:text-sm file:font-semibold
                file:bg-blue-50 file:text-blue-700
                hover:file:bg-blue-100
                dark:file:bg-blue-900 dark:file:text-blue-300
                dark:hover:file:bg-blue-800"</span>
            /&gt;
          &lt;/div&gt;

          {file &amp;&amp; (
            &lt;div className=<span class="hljs-string">"mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg text-sm text-gray-600 dark:text-gray-400 space-y-1"</span>&gt;
              &lt;p&gt;&lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Selected:&lt;<span class="hljs-regexp">/span&gt; {file.name}&lt;/</span>p&gt;
              &lt;p&gt;&lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Size:&lt;<span class="hljs-regexp">/span&gt; {(file.size /</span> <span class="hljs-number">1024</span>).toFixed(<span class="hljs-number">2</span>)} KB&lt;/p&gt;
              &lt;p&gt;&lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Type:&lt;<span class="hljs-regexp">/span&gt; {file.type || file.name.split('.').pop()}&lt;/</span>p&gt;
            &lt;/div&gt;
          )}

          &lt;button
            onClick={handleUpload}
            disabled={!file || uploading}
            className=<span class="hljs-string">"w-full bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"</span>
          &gt;
            {uploading ? <span class="hljs-string">'Uploading and Processing...'</span> : <span class="hljs-string">'Upload Document'</span>}
          &lt;/button&gt;

          {message &amp;&amp; (
            &lt;div
              className={<span class="hljs-string">`mt-6 p-4 rounded-lg <span class="hljs-subst">${
                message.<span class="hljs-keyword">type</span> === <span class="hljs-string">'success'</span>
                  ? <span class="hljs-string">'bg-green-50 text-green-800 dark:bg-green-900 dark:text-green-200'</span>
                  : <span class="hljs-string">'bg-red-50 text-red-800 dark:bg-red-900 dark:text-red-200'</span>
              }</span>`</span>}
            &gt;
              {message.text}
            &lt;/div&gt;
          )}

          &lt;div className=<span class="hljs-string">"mt-8 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm"</span>&gt;
            &lt;p className=<span class="hljs-string">"font-medium text-blue-900 dark:text-blue-200 mb-2"</span>&gt;Supported: PDF, DOCX, TXT&lt;/p&gt;
            &lt;p className=<span class="hljs-string">"text-blue-700 dark:text-blue-400"</span>&gt;Files will be processed and embedded <span class="hljs-keyword">for</span> RAG search.&lt;/p&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component provides a clean interface for file uploads with proper error handling and user feedback. Next, let's create the PDF viewer component for previewing documents.</p>
<h2 id="heading-step-9-create-the-pdf-viewer-modal-component">Step 9: Create the PDF Viewer Modal Component</h2>
<p>The PDF viewer modal allows users to preview PDFs and view extracted text from any document. It's particularly useful for verifying that documents were processed correctly.</p>
<p>Create <code>src/app/components/PDFViewerModal.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;
<span class="hljs-keyword">import</span> { useEffect, useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;

<span class="hljs-keyword">interface</span> PDFViewerModalProps {
  isOpen: <span class="hljs-built_in">boolean</span>;
  onClose: <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</span>;
  fileUrl: <span class="hljs-built_in">string</span>;
  fileName: <span class="hljs-built_in">string</span>;
  documentId?: <span class="hljs-built_in">string</span>;
  isPDF?: <span class="hljs-built_in">boolean</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">PDFViewerModal</span>(<span class="hljs-params">{ 
  isOpen, 
  onClose, 
  fileUrl, 
  fileName, 
  documentId, 
  isPDF = <span class="hljs-literal">true</span> 
}: PDFViewerModalProps</span>) </span>{
  <span class="hljs-keyword">const</span> [error, setError] = useState&lt;<span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">true</span>);
  <span class="hljs-keyword">const</span> [activeTab, setActiveTab] = useState&lt;<span class="hljs-string">'preview'</span> | <span class="hljs-string">'content'</span>&gt;(<span class="hljs-string">'preview'</span>);
  <span class="hljs-keyword">const</span> [text, setText] = useState&lt;<span class="hljs-built_in">string</span>&gt;(<span class="hljs-string">''</span>);
  <span class="hljs-keyword">const</span> [textLoading, setTextLoading] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [textError, setTextError] = useState&lt;<span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-built_in">document</span>.body.style.overflow = isOpen ? <span class="hljs-string">'hidden'</span> : <span class="hljs-string">'unset'</span>;
    <span class="hljs-keyword">if</span> (isOpen) { 
      setError(<span class="hljs-literal">null</span>); 
      setLoading(<span class="hljs-literal">true</span>); 
      setActiveTab(isPDF ? <span class="hljs-string">'preview'</span> : <span class="hljs-string">'content'</span>); 
      setText(<span class="hljs-string">''</span>); 
      setTextError(<span class="hljs-literal">null</span>); 
    }
    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> { 
      <span class="hljs-built_in">document</span>.body.style.overflow = <span class="hljs-string">'unset'</span>; 
    };
  }, [isOpen, isPDF]);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">if</span> (isOpen &amp;&amp; documentId &amp;&amp; activeTab === <span class="hljs-string">'content'</span> &amp;&amp; !text &amp;&amp; !textLoading &amp;&amp; !textError) {
      fetchDocumentText();
    }
  }, [isOpen, documentId, activeTab, text, textLoading, textError]);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">if</span> (isOpen &amp;&amp; fileUrl &amp;&amp; isPDF) {
      fetch(fileUrl, { method: <span class="hljs-string">'GET'</span>, headers: { <span class="hljs-string">'Accept'</span>: <span class="hljs-string">'application/json'</span> } })
        .then(<span class="hljs-keyword">async</span> res =&gt; {
          <span class="hljs-keyword">if</span> (res.headers.get(<span class="hljs-string">'content-type'</span>)?.includes(<span class="hljs-string">'application/json'</span>)) {
            <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
            <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(data.error || <span class="hljs-string">'File not available'</span>);
          }
          <span class="hljs-keyword">if</span> (!res.ok) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">`Failed to load: <span class="hljs-subst">${res.status}</span>`</span>);
          setLoading(<span class="hljs-literal">false</span>);
        })
        .catch(<span class="hljs-function"><span class="hljs-params">err</span> =&gt;</span> {
          setError(err.message || <span class="hljs-string">'Failed to load PDF'</span>);
          setLoading(<span class="hljs-literal">false</span>);
        });
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (isOpen &amp;&amp; !isPDF) {
      setLoading(<span class="hljs-literal">false</span>);
    }
  }, [isOpen, fileUrl, isPDF]);

  <span class="hljs-keyword">const</span> fetchDocumentText = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">if</span> (!documentId) <span class="hljs-keyword">return</span>;
    setTextLoading(<span class="hljs-literal">true</span>); 
    setTextError(<span class="hljs-literal">null</span>);
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${documentId}</span>`</span>);
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
      <span class="hljs-keyword">if</span> (data.error) {
        setTextError(data.error);
      } <span class="hljs-keyword">else</span> {
        setText(data.fullText || <span class="hljs-string">'No text content available'</span>);
      }
    } <span class="hljs-keyword">catch</span> (err) {
      setTextError(err <span class="hljs-keyword">instanceof</span> <span class="hljs-built_in">Error</span> ? err.message : <span class="hljs-string">'Failed to fetch document text'</span>);
    } <span class="hljs-keyword">finally</span> {
      setTextLoading(<span class="hljs-literal">false</span>);
    }
  };

  <span class="hljs-keyword">if</span> (!isOpen) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;

  <span class="hljs-keyword">return</span> (
    &lt;div
      className=<span class="hljs-string">"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 p-4"</span>
      onClick={onClose}
    &gt;
      &lt;div
        className=<span class="hljs-string">"relative bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-6xl h-[90vh] flex flex-col"</span>
        onClick={<span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> e.stopPropagation()}
      &gt;
        &lt;div className=<span class="hljs-string">"flex flex-col border-b border-gray-200 dark:border-gray-800"</span>&gt;
          &lt;div className=<span class="hljs-string">"flex items-center justify-between p-4"</span>&gt;
            &lt;h2 className=<span class="hljs-string">"text-xl font-semibold text-gray-900 dark:text-gray-100 truncate flex-1 mr-4"</span>&gt;
              {fileName}
            &lt;/h2&gt;
            &lt;div className=<span class="hljs-string">"flex items-center gap-2"</span>&gt;
              &lt;button
                onClick={onClose}
                className=<span class="hljs-string">"p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"</span>
                aria-label=<span class="hljs-string">"Close"</span>
              &gt;
                &lt;svg className=<span class="hljs-string">"w-6 h-6"</span> fill=<span class="hljs-string">"none"</span> stroke=<span class="hljs-string">"currentColor"</span> viewBox=<span class="hljs-string">"0 0 24 24"</span>&gt;
                  &lt;path strokeLinecap=<span class="hljs-string">"round"</span> strokeLinejoin=<span class="hljs-string">"round"</span> strokeWidth={<span class="hljs-number">2</span>} d=<span class="hljs-string">"M6 18L18 6M6 6l12 12"</span> /&gt;
                &lt;/svg&gt;
              &lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;

          {isPDF &amp;&amp; (
            &lt;div className=<span class="hljs-string">"flex border-t border-gray-200 dark:border-gray-800"</span>&gt;
              {([<span class="hljs-string">'preview'</span>, <span class="hljs-string">'content'</span>] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>).map(<span class="hljs-function"><span class="hljs-params">tab</span> =&gt;</span> (
                &lt;button 
                  key={tab} 
                  onClick={<span class="hljs-function">() =&gt;</span> setActiveTab(tab)} 
                  className={<span class="hljs-string">`flex-1 px-4 py-3 text-sm font-medium transition-colors <span class="hljs-subst">${
                    activeTab === tab 
                      ? <span class="hljs-string">'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20'</span> 
                      : <span class="hljs-string">'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'</span>
                  }</span>`</span>}
                &gt;
                  {tab.charAt(<span class="hljs-number">0</span>).toUpperCase() + tab.slice(<span class="hljs-number">1</span>)}
                &lt;/button&gt;
              ))}
            &lt;/div&gt;
          )}
        &lt;/div&gt;

        &lt;div className=<span class="hljs-string">"flex-1 overflow-hidden"</span>&gt;
          {isPDF &amp;&amp; activeTab === <span class="hljs-string">'preview'</span> &amp;&amp; (
            &lt;div className=<span class="hljs-string">"h-full overflow-hidden"</span>&gt;
              {error ? (
                &lt;div className=<span class="hljs-string">"flex flex-col items-center justify-center h-full p-8"</span>&gt;
                  &lt;div className=<span class="hljs-string">"bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 max-w-md"</span>&gt;
                    &lt;h3 className=<span class="hljs-string">"text-lg font-semibold text-yellow-800 dark:text-yellow-200 mb-2"</span>&gt;
                      PDF File Not Available
                    &lt;/h3&gt;
                    &lt;p className=<span class="hljs-string">"text-yellow-700 dark:text-yellow-300 mb-4"</span>&gt;{error}&lt;/p&gt;
                    {documentId &amp;&amp; (
                      &lt;button 
                        onClick={<span class="hljs-function">() =&gt;</span> setActiveTab(<span class="hljs-string">'content'</span>)} 
                        className=<span class="hljs-string">"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"</span>
                      &gt;
                        View Extracted Text Instead
                      &lt;/button&gt;
                    )}
                  &lt;/div&gt;
                &lt;/div&gt;
              ) : loading ? (
                &lt;div className=<span class="hljs-string">"flex items-center justify-center h-full"</span>&gt;
                  &lt;p className=<span class="hljs-string">"text-gray-500 dark:text-gray-400"</span>&gt;Loading PDF...&lt;/p&gt;
                &lt;/div&gt;
              ) : (
                &lt;iframe
                  src={<span class="hljs-string">`<span class="hljs-subst">${fileUrl}</span><span class="hljs-subst">${fileUrl.includes(<span class="hljs-string">'?'</span>) ? <span class="hljs-string">'&amp;'</span> : <span class="hljs-string">'?'</span>}</span>view=true#toolbar=1&amp;navpanes=0&amp;scrollbar=1`</span>}
                  className=<span class="hljs-string">"w-full h-full border-0"</span>
                  title={fileName}
                  allow=<span class="hljs-string">"fullscreen"</span>
                  onError={<span class="hljs-function">() =&gt;</span> setError(<span class="hljs-string">'Failed to load PDF'</span>)}
                /&gt;
              )}
            &lt;/div&gt;
          )}

          {(!isPDF || activeTab === <span class="hljs-string">'content'</span>) &amp;&amp; (
            &lt;div className=<span class="hljs-string">"h-full overflow-auto p-6"</span>&gt;
              {textLoading ? (
                &lt;div className=<span class="hljs-string">"flex items-center justify-center h-full"</span>&gt;
                  &lt;p className=<span class="hljs-string">"text-gray-500 dark:text-gray-400"</span>&gt;Loading...&lt;/p&gt;
                &lt;/div&gt;
              ) : textError ? (
                &lt;div className=<span class="hljs-string">"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"</span>&gt;
                  &lt;p className=<span class="hljs-string">"text-red-800 dark:text-red-200"</span>&gt;<span class="hljs-built_in">Error</span>: {textError}&lt;/p&gt;
                &lt;/div&gt;
              ) : (
                &lt;div className=<span class="hljs-string">"space-y-4"</span>&gt;
                  &lt;p className=<span class="hljs-string">"text-sm text-gray-500 dark:text-gray-400"</span>&gt;
                    Formatting may be inconsistent <span class="hljs-keyword">from</span> source.
                  &lt;/p&gt;
                  &lt;pre className=<span class="hljs-string">"whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-200 font-mono bg-gray-50 dark:bg-gray-800 p-4 rounded-lg"</span>&gt;
                    {text || <span class="hljs-string">'No text content available'</span>}
                  &lt;/pre&gt;
                &lt;/div&gt;
              )}
            &lt;/div&gt;
          )}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component provides a full-screen modal for viewing PDFs and extracted text, with tabs to switch between preview and text content. Now let's create a simple navigation component to tie everything together.</p>
<h2 id="heading-step-10-create-the-navigation-component">Step 10: Create the Navigation Component</h2>
<p>The navigation component provides easy access to the Search and Documents pages. It highlights the current page and provides a clean, consistent navigation experience.</p>
<p>Create <code>src/app/components/Navigation.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;
<span class="hljs-keyword">import</span> Link <span class="hljs-keyword">from</span> <span class="hljs-string">'next/link'</span>;
<span class="hljs-keyword">import</span> { usePathname } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/navigation'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Navigation</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> pathname = usePathname();

  <span class="hljs-keyword">const</span> navItems = [
    { href: <span class="hljs-string">'/'</span>, label: <span class="hljs-string">'Search'</span> },
    { href: <span class="hljs-string">'/documents'</span>, label: <span class="hljs-string">'Documents'</span> },
  ];

  <span class="hljs-keyword">return</span> (
    &lt;nav className=<span class="hljs-string">"border-b border-gray-200 dark:border-gray-800 mb-8"</span>&gt;
      &lt;div className=<span class="hljs-string">"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"</span>&gt;
        &lt;div className=<span class="hljs-string">"flex space-x-8"</span>&gt;
          {navItems.map(<span class="hljs-function">(<span class="hljs-params">item</span>) =&gt;</span> (
            &lt;Link
              key={item.href}
              href={item.href}
              className={<span class="hljs-string">`py-4 px-1 border-b-2 font-medium text-sm <span class="hljs-subst">${
                pathname === item.href
                  ? <span class="hljs-string">'border-blue-500 text-blue-600 dark:text-blue-400'</span>
                  : <span class="hljs-string">'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'</span>
              }</span>`</span>}
            &gt;
              {item.label}
            &lt;/Link&gt;
          ))}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/nav&gt;
  );
}
</code></pre>
<p>With navigation in place, let's create the main search page where users can query their documents.</p>
<h2 id="heading-step-11-create-the-home-page-search-interface">Step 11: Create the Home Page (Search Interface)</h2>
<p>The search page is the main interface where users ask questions about their uploaded documents. It displays the AI-generated answers along with source citations, allowing users to verify the information.</p>
<p>Update <code>src/app/page.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;
<span class="hljs-keyword">import</span> { useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> Navigation <span class="hljs-keyword">from</span> <span class="hljs-string">'./components/Navigation'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">Home</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> [query, setQuery] = useState(<span class="hljs-string">''</span>);
  <span class="hljs-keyword">const</span> [answer, setAnswer] = useState(<span class="hljs-string">''</span>);
  <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [sources, setSources] = useState&lt;<span class="hljs-built_in">any</span>[]&gt;([]);

  <span class="hljs-keyword">const</span> handleSearch = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">if</span> (!query.trim()) <span class="hljs-keyword">return</span>;
    setLoading(<span class="hljs-literal">true</span>); 
    setAnswer(<span class="hljs-string">''</span>); 
    setSources([]);
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">'/api/search'</span>, { 
        method: <span class="hljs-string">'POST'</span>, 
        headers: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span> }, 
        body: <span class="hljs-built_in">JSON</span>.stringify({ query }) 
      });
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
      <span class="hljs-keyword">if</span> (data.error) {
        setAnswer(<span class="hljs-string">`Error: <span class="hljs-subst">${data.error}</span>`</span>);
      } <span class="hljs-keyword">else</span> { 
        setAnswer(data.answer || <span class="hljs-string">'No answer generated'</span>); 
        setSources(data.sources || []); 
      }
    } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
      setAnswer(<span class="hljs-string">`Error: <span class="hljs-subst">${error.message}</span>`</span>);
    } <span class="hljs-keyword">finally</span> {
      setLoading(<span class="hljs-literal">false</span>);
    }
  };

  <span class="hljs-keyword">const</span> handleKeyPress = <span class="hljs-function">(<span class="hljs-params">e: React.KeyboardEvent</span>) =&gt;</span> {
    <span class="hljs-keyword">if</span> (e.key === <span class="hljs-string">'Enter'</span> &amp;&amp; (e.metaKey || e.ctrlKey)) {
      handleSearch();
    }
  };

  <span class="hljs-keyword">return</span> (
    &lt;div className=<span class="hljs-string">"min-h-screen"</span>&gt;
      &lt;Navigation /&gt;
      &lt;main className=<span class="hljs-string">"max-w-4xl mx-auto p-8"</span>&gt;
        &lt;h1 className=<span class="hljs-string">"text-3xl font-bold mb-6"</span>&gt;RAG Search&lt;/h1&gt;

        &lt;div className=<span class="hljs-string">"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg p-6 shadow-sm mb-6"</span>&gt;
          &lt;textarea 
            className=<span class="hljs-string">"w-full p-4 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"</span>
            placeholder=<span class="hljs-string">"Ask a question about your uploaded documents..."</span>
            value={query}
            onChange={<span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> setQuery(e.target.value)}
            onKeyDown={handleKeyPress}
            rows={<span class="hljs-number">4</span>}
          /&gt;
          &lt;button 
            onClick={handleSearch}
            className=<span class="hljs-string">"mt-4 bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"</span>
            disabled={loading || !query.trim()}
          &gt;
            {loading ? <span class="hljs-string">'Searching...'</span> : <span class="hljs-string">'Search'</span>}
          &lt;/button&gt;
          &lt;p className=<span class="hljs-string">"mt-2 text-sm text-gray-500 dark:text-gray-400"</span>&gt;
            Press Cmd/Ctrl + Enter to search
          &lt;/p&gt;
        &lt;/div&gt;

        {answer &amp;&amp; (
          &lt;div className=<span class="hljs-string">"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg p-6 shadow-sm mb-6"</span>&gt;
            &lt;h2 className=<span class="hljs-string">"text-xl font-semibold mb-3"</span>&gt;Answer:&lt;/h2&gt;
            &lt;p className=<span class="hljs-string">"text-gray-800 dark:text-gray-200 leading-relaxed whitespace-pre-wrap"</span>&gt;
              {answer}
            &lt;/p&gt;
          &lt;/div&gt;
        )}

        {sources &amp;&amp; sources.length &gt; <span class="hljs-number">0</span> &amp;&amp; (
          &lt;div className=<span class="hljs-string">"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg p-6 shadow-sm"</span>&gt;
            &lt;h2 className=<span class="hljs-string">"text-xl font-semibold mb-3"</span>&gt;Sources ({sources.length}):&lt;/h2&gt;
            &lt;div className=<span class="hljs-string">"space-y-3"</span>&gt;
              {sources.map(<span class="hljs-function">(<span class="hljs-params">source, index</span>) =&gt;</span> (
                &lt;div
                  key={index}
                  className=<span class="hljs-string">"p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"</span>
                &gt;
                  &lt;p className=<span class="hljs-string">"text-sm text-gray-600 dark:text-gray-400 mb-1"</span>&gt;
                    &lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Source:&lt;/span&gt;{<span class="hljs-string">' '</span>}
                    {source.metadata?.source || source.metadata?.file_name || <span class="hljs-string">'Unknown'</span>}
                  &lt;/p&gt;
                  &lt;p className=<span class="hljs-string">"text-sm text-gray-800 dark:text-gray-200 line-clamp-3"</span>&gt;
                    {source.content}
                  &lt;/p&gt;
                &lt;/div&gt;
              ))}
            &lt;/div&gt;
          &lt;/div&gt;
        )}
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>This page provides a clean search interface with a textarea for queries, a search button, and sections to display answers and source citations. The sources section helps users verify where the information came from, which is crucial for trust and accuracy. Now let's create the documents management page.</p>
<h2 id="heading-step-12-create-the-documents-page">Step 12: Create the Documents Page</h2>
<p>The documents page serves as your document library. It displays all uploaded documents in a table format, shows metadata like file size and chunk count, and provides actions to preview, download, or delete documents. This page is essential for managing your document collection and verifying uploads.</p>
<p>Create <code>src/app/documents/page.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;
<span class="hljs-keyword">import</span> { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> Navigation <span class="hljs-keyword">from</span> <span class="hljs-string">'../components/Navigation'</span>;
<span class="hljs-keyword">import</span> PDFViewerModal <span class="hljs-keyword">from</span> <span class="hljs-string">'../components/PDFViewerModal'</span>;
<span class="hljs-keyword">import</span> UploadModal <span class="hljs-keyword">from</span> <span class="hljs-string">'../components/UploadModal'</span>;

<span class="hljs-keyword">interface</span> Document {
  id: <span class="hljs-built_in">string</span>;
  file_name: <span class="hljs-built_in">string</span>;
  file_type: <span class="hljs-built_in">string</span>;
  file_size: <span class="hljs-built_in">number</span>;
  upload_date: <span class="hljs-built_in">string</span>;
  total_chunks: <span class="hljs-built_in">number</span>;
  file_url?: <span class="hljs-built_in">string</span>;
  file_path?: <span class="hljs-built_in">string</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">DocumentsPage</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> [documents, setDocuments] = useState&lt;Document[]&gt;([]);
  <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">true</span>);
  <span class="hljs-keyword">const</span> [error, setError] = useState&lt;<span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [showPDFModal, setShowPDFModal] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [selectedPDF, setSelectedPDF] = useState&lt;{ url: <span class="hljs-built_in">string</span>; name: <span class="hljs-built_in">string</span>; id?: <span class="hljs-built_in">string</span>; isPDF?: <span class="hljs-built_in">boolean</span> } | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [deletingId, setDeletingId] = useState&lt;<span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [showUploadModal, setShowUploadModal] = useState(<span class="hljs-literal">false</span>);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    fetchDocuments();
  }, []);

  <span class="hljs-keyword">const</span> fetchDocuments = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">try</span> {
      setLoading(<span class="hljs-literal">true</span>);
      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">'/api/documents'</span>);
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
      <span class="hljs-keyword">if</span> (data.error) {
        setError(data.error);
      } <span class="hljs-keyword">else</span> {
        setDocuments(data.documents || []);
      }
    } <span class="hljs-keyword">catch</span> (err) {
      setError(err <span class="hljs-keyword">instanceof</span> <span class="hljs-built_in">Error</span> ? err.message : <span class="hljs-string">'Failed to fetch documents'</span>);
    } <span class="hljs-keyword">finally</span> {
      setLoading(<span class="hljs-literal">false</span>);
    }
  };

  <span class="hljs-keyword">const</span> formatDate = <span class="hljs-function">(<span class="hljs-params">s: <span class="hljs-built_in">string</span></span>) =&gt;</span> {
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> d = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>(s);
      <span class="hljs-keyword">return</span> <span class="hljs-built_in">isNaN</span>(d.getTime()) 
        ? s 
        : d.toLocaleString(<span class="hljs-string">'en-US'</span>, { 
            year: <span class="hljs-string">'numeric'</span>, 
            month: <span class="hljs-string">'short'</span>, 
            day: <span class="hljs-string">'numeric'</span>, 
            hour: <span class="hljs-string">'2-digit'</span>, 
            minute: <span class="hljs-string">'2-digit'</span>, 
            hour12: <span class="hljs-literal">true</span> 
          });
    } <span class="hljs-keyword">catch</span> { 
      <span class="hljs-keyword">return</span> s; 
    }
  };

  <span class="hljs-keyword">const</span> formatFileSize = <span class="hljs-function">(<span class="hljs-params">b: <span class="hljs-built_in">number</span></span>) =&gt;</span> 
    b &lt; <span class="hljs-number">1024</span> 
      ? <span class="hljs-string">`<span class="hljs-subst">${b}</span> B`</span> 
      : b &lt; <span class="hljs-number">1024</span> * <span class="hljs-number">1024</span> 
        ? <span class="hljs-string">`<span class="hljs-subst">${(b / <span class="hljs-number">1024</span>).toFixed(<span class="hljs-number">2</span>)}</span> KB`</span> 
        : <span class="hljs-string">`<span class="hljs-subst">${(b / (<span class="hljs-number">1024</span> * <span class="hljs-number">1024</span>)).toFixed(<span class="hljs-number">2</span>)}</span> MB`</span>;

  <span class="hljs-keyword">const</span> handleDelete = <span class="hljs-keyword">async</span> (id: <span class="hljs-built_in">string</span>, name: <span class="hljs-built_in">string</span>) =&gt; {
    <span class="hljs-keyword">if</span> (!confirm(<span class="hljs-string">`Delete "<span class="hljs-subst">${name}</span>"? This will permanently delete the document, embeddings, and file.`</span>)) {
      <span class="hljs-keyword">return</span>;
    }
    setDeletingId(id);
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${id}</span>`</span>, { method: <span class="hljs-string">'DELETE'</span> });
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
      <span class="hljs-keyword">if</span> (data.error) {
        alert(<span class="hljs-string">`Error: <span class="hljs-subst">${data.error}</span>`</span>);
      } <span class="hljs-keyword">else</span> {
        setDocuments(documents.filter(<span class="hljs-function"><span class="hljs-params">doc</span> =&gt;</span> doc.id !== id));
      }
    } <span class="hljs-keyword">catch</span> (err) {
      alert(err <span class="hljs-keyword">instanceof</span> <span class="hljs-built_in">Error</span> ? err.message : <span class="hljs-string">'Failed to delete'</span>);
    } <span class="hljs-keyword">finally</span> {
      setDeletingId(<span class="hljs-literal">null</span>);
    }
  };

  <span class="hljs-keyword">return</span> (
    &lt;div className=<span class="hljs-string">"min-h-screen"</span>&gt;
      &lt;Navigation /&gt;
      &lt;main className=<span class="hljs-string">"max-w-7xl mx-auto p-8"</span>&gt;
        &lt;div className=<span class="hljs-string">"flex items-center justify-between mb-6"</span>&gt;
          &lt;h1 className=<span class="hljs-string">"text-3xl font-bold"</span>&gt;Documents&lt;/h1&gt;
          &lt;button
            onClick={<span class="hljs-function">() =&gt;</span> setShowUploadModal(<span class="hljs-literal">true</span>)}
            className=<span class="hljs-string">"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"</span>
          &gt;
            Upload Document
          &lt;/button&gt;
        &lt;/div&gt;

        {loading ? (
          &lt;div className=<span class="hljs-string">"text-center py-12"</span>&gt;
            &lt;p className=<span class="hljs-string">"text-gray-500 dark:text-gray-400"</span>&gt;Loading documents...&lt;/p&gt;
          &lt;/div&gt;
        ) : error ? (
          &lt;div className=<span class="hljs-string">"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"</span>&gt;
            &lt;p className=<span class="hljs-string">"text-red-800 dark:text-red-200"</span>&gt;<span class="hljs-built_in">Error</span>: {error}&lt;/p&gt;
          &lt;/div&gt;
        ) : documents.length === <span class="hljs-number">0</span> ? (
          &lt;div className=<span class="hljs-string">"bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-12 text-center"</span>&gt;
            &lt;p className=<span class="hljs-string">"text-gray-500 dark:text-gray-400 mb-4"</span>&gt;No documents uploaded yet.&lt;/p&gt;
            &lt;button
              onClick={<span class="hljs-function">() =&gt;</span> setShowUploadModal(<span class="hljs-literal">true</span>)}
              className=<span class="hljs-string">"text-blue-600 dark:text-blue-400 hover:underline font-medium"</span>
            &gt;
              Upload your first <span class="hljs-built_in">document</span>
            &lt;/button&gt;
          &lt;/div&gt;
        ) : (
          &lt;div className=<span class="hljs-string">"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg shadow-sm overflow-hidden"</span>&gt;
            &lt;div className=<span class="hljs-string">"overflow-x-auto"</span>&gt;
              &lt;table className=<span class="hljs-string">"min-w-full divide-y divide-gray-200 dark:divide-gray-800"</span>&gt;
                &lt;thead className=<span class="hljs-string">"bg-gray-50 dark:bg-gray-800"</span>&gt;
                  &lt;tr&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      File Name
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Type
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Size
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Chunks
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Upload <span class="hljs-built_in">Date</span>
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Actions
                    &lt;/th&gt;
                  &lt;/tr&gt;
                &lt;/thead&gt;
                &lt;tbody className=<span class="hljs-string">"bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800"</span>&gt;
                  {documents.map(<span class="hljs-function">(<span class="hljs-params">doc</span>) =&gt;</span> (
                    &lt;tr key={doc.id} className=<span class="hljs-string">"hover:bg-gray-50 dark:hover:bg-gray-800"</span>&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap"</span>&gt;
                        &lt;div className=<span class="hljs-string">"text-sm font-medium text-gray-900 dark:text-gray-100"</span>&gt;
                          {doc.file_name}
                        &lt;/div&gt;
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap"</span>&gt;
                        &lt;span className=<span class="hljs-string">"px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"</span>&gt;
                          {doc.file_type || <span class="hljs-string">'unknown'</span>}
                        &lt;/span&gt;
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"</span>&gt;
                        {formatFileSize(doc.file_size)}
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"</span>&gt;
                        {doc.total_chunks}
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"</span>&gt;
                        {formatDate(doc.upload_date)}
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap text-sm font-medium"</span>&gt;
                        &lt;div className=<span class="hljs-string">"flex gap-3 items-center"</span>&gt;
                          {doc.file_name.toLowerCase().endsWith(<span class="hljs-string">'.pdf'</span>) ? (
                            &lt;button 
                              onClick={<span class="hljs-function">() =&gt;</span> {
                                <span class="hljs-keyword">const</span> pdfUrl = doc.file_url 
                                  ? <span class="hljs-string">`<span class="hljs-subst">${doc.file_url}</span>?view=true`</span> 
                                  : <span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${doc.id}</span>&amp;file=true&amp;view=true`</span>;
                                setSelectedPDF({ url: pdfUrl, name: doc.file_name, id: doc.id });
                                setShowPDFModal(<span class="hljs-literal">true</span>);
                              }} 
                              className=<span class="hljs-string">"text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"</span>
                            &gt;
                              Preview
                            &lt;/button&gt;
                          ) : (
                            &lt;&gt;
                              &lt;button 
                                onClick={<span class="hljs-function">() =&gt;</span> {
                                  setSelectedPDF({ 
                                    url: doc.file_url || <span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${doc.id}</span>&amp;file=true`</span>, 
                                    name: doc.file_name, 
                                    id: doc.id, 
                                    isPDF: <span class="hljs-literal">false</span> 
                                  });
                                  setShowPDFModal(<span class="hljs-literal">true</span>);
                                }} 
                                className=<span class="hljs-string">"text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"</span>
                              &gt;
                                View
                              &lt;/button&gt;
                              {(doc.file_url || doc.file_path) &amp;&amp; (
                                &lt;a 
                                  href={doc.file_url || <span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${doc.id}</span>&amp;file=true`</span>} 
                                  download={doc.file_name}
                                  className=<span class="hljs-string">"text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"</span> 
                                  target=<span class="hljs-string">"_blank"</span> 
                                  rel=<span class="hljs-string">"noopener noreferrer"</span>
                                &gt;
                                  Download
                                &lt;/a&gt;
                              )}
                            &lt;/&gt;
                          )}
                          &lt;button 
                            onClick={<span class="hljs-function">() =&gt;</span> handleDelete(doc.id, doc.file_name)} 
                            disabled={deletingId === doc.id}
                            className=<span class="hljs-string">"text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50 disabled:cursor-not-allowed"</span>
                          &gt;
                            {deletingId === doc.id ? <span class="hljs-string">'Deleting...'</span> : <span class="hljs-string">'Delete'</span>}
                          &lt;/button&gt;
                        &lt;/div&gt;
                      &lt;/td&gt;
                    &lt;/tr&gt;
                  ))}
                &lt;/tbody&gt;
              &lt;/table&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        )}

        {selectedPDF &amp;&amp; (
          &lt;PDFViewerModal 
            isOpen={showPDFModal} 
            onClose={<span class="hljs-function">() =&gt;</span> { 
              setShowPDFModal(<span class="hljs-literal">false</span>); 
              setSelectedPDF(<span class="hljs-literal">null</span>); 
            }}
            fileUrl={selectedPDF.url} 
            fileName={selectedPDF.name} 
            documentId={selectedPDF.id} 
            isPDF={selectedPDF.isPDF !== <span class="hljs-literal">false</span>} 
          /&gt;
        )}
        &lt;UploadModal 
          isOpen={showUploadModal} 
          onClose={<span class="hljs-function">() =&gt;</span> setShowUploadModal(<span class="hljs-literal">false</span>)} 
          onUploadSuccess={fetchDocuments} 
        /&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>This page provides a comprehensive document management interface with a table showing all documents, their metadata, and action buttons for preview, download, and deletion. The page automatically refreshes after uploads and handles loading and error states gracefully.</p>
<p>Now that all your components and pages are built, let's test the complete application.</p>
<h2 id="heading-step-13-test-your-application">Step 13: Test Your Application</h2>
<p>Start your development server:</p>
<pre><code class="lang-typescript">npm run dev
</code></pre>
<p>Open <a target="_blank" href="http://localhost:3000/"><strong>http://localhost:3000</strong></a> in your browser.</p>
<h3 id="heading-test-the-upload-flow">Test the Upload Flow</h3>
<ol>
<li><p>Navigate to the Documents page</p>
</li>
<li><p>Click "Upload Document"</p>
</li>
<li><p>Select a PDF, DOCX, or TXT file</p>
</li>
<li><p>Wait for the upload and processing to complete (this may take a moment as embeddings are generated)</p>
</li>
<li><p>You should see your document in the list with its metadata:</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769376932518/cf1bcd3c-3ab2-4602-8df0-bca909c0edb0.png" alt="RAG search documents management page showing a table with uploaded documents." class="image--center mx-auto" width="2026" height="1296" loading="lazy"></p>
<h3 id="heading-test-the-search-flow">Test the Search Flow</h3>
<ol>
<li><p>Navigate to the Search page (or click "Search" in the navigation)</p>
</li>
<li><p>Make sure you've uploaded at least one document first</p>
</li>
<li><p>Type a question about your uploaded document (for example, "What is this document about?" or ask about specific content)</p>
</li>
<li><p>Click "Search" or press Cmd/Ctrl + Enter</p>
</li>
<li><p>You should see an AI-generated answer with source citations showing which document chunks were used</p>
</li>
</ol>
<p>Once the embedding is done, you can navigate to search and look for the sample test command based on the documents you have uploaded. You can also check the source from which the search results were pulled.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769377080953/c15678d6-59d0-4e97-8a1b-fe049e5fa6a9.png" alt="RAG Search application search interface showing a query input to search from the RAG database." class="image--center mx-auto" width="2390" height="1900" loading="lazy"></p>
<h3 id="heading-test-document-management">Test Document Management</h3>
<ol>
<li><p>On the Documents page, click "Preview" or "View" on a document</p>
</li>
<li><p>Try downloading a document</p>
</li>
<li><p>Test deleting a document (be careful - this is permanent)</p>
</li>
</ol>
<p>If everything works correctly, you're ready to deploy your application!</p>
<h2 id="heading-step-14-deploy-your-application">Step 14: Deploy Your Application</h2>
<h3 id="heading-deploy-to-vercel">Deploy to Vercel</h3>
<p>Vercel is the easiest way to deploy Next.js applications and is made by the creators of Next.js:</p>
<p>To get started, you’ll need to push your code to GitHub. So go ahead and create a repository and push your code.</p>
<p>Then go to <a target="_blank" href="https://vercel.com/"><strong>vercel.com</strong></a> and sign in with your GitHub account. Click "New Project" and import your GitHub repository.</p>
<p>Add your environment variables in the project settings:</p>
<ul>
<li><p><code>NEXT_PUBLIC_SUPABASE_URL</code></p>
</li>
<li><p><code>NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code></p>
</li>
<li><p><code>SUPABASE_SERVICE_ROLE_KEY</code></p>
</li>
<li><p><code>OPENAI_API_KEY</code></p>
</li>
</ul>
<p>Then click "Deploy", and your application will be live in minutes! Vercel automatically builds and deploys your Next.js application, and you'll get a URL like <a target="_blank" href="http://your-app.vercel.app/"><code>your-app.vercel.app</code></a>.</p>
<h3 id="heading-important-deployment-notes">Important Deployment Notes</h3>
<ul>
<li><p>Make sure all environment variables are set in your Vercel project settings</p>
</li>
<li><p>The service role key is required for file uploads to work</p>
</li>
<li><p>Supabase Storage bucket should be accessible (public or with proper RLS policies)</p>
</li>
<li><p>Your OpenAI API key should have sufficient credits</p>
</li>
</ul>
<h2 id="heading-how-rag-search-works">How RAG Search Works</h2>
<p>Your application uses the RAG (Retrieval-Augmented Generation) pattern. This combines information retrieval with AI text generation. Here's how it works step by step:</p>
<ol>
<li><p><strong>Document processing</strong>: When you upload a document, it's split into chunks. These are typically 800 characters each with 100-character overlap. Each chunk gets an embedding. This is a 1536-dimensional vector that represents its semantic meaning.</p>
</li>
<li><p><strong>Storage</strong>: Embeddings are stored in a vector database. This is PostgreSQL with the pgvector extension. They're stored alongside the original text chunks. The original files are stored in Supabase Storage.</p>
</li>
<li><p><strong>Query processing</strong>: When you search, your query is converted into an embedding. It uses the same model that processed the documents. This ensures the query and documents are in the same "vector space."</p>
</li>
<li><p><strong>Similarity search</strong>: The system finds the most similar document chunks. It uses cosine similarity on the embeddings. Cosine similarity measures the angle between vectors. Smaller angles mean more similar content, even if the exact words differ.</p>
</li>
<li><p><strong>Answer generation</strong>: The retrieved chunks are used as context for an AI model. This model is GPT-4o-mini. It generates an accurate answer. The system prompt instructs the AI to only answer based on the provided context. This ensures accuracy.</p>
</li>
</ol>
<p>This approach gives you several benefits.</p>
<p>First, you get accuracy. Answers are based on your actual documents, not just the AI's training data. Second, you get transparency. You can see which document chunks were used to generate each answer. Third, you get efficiency. Only relevant chunks are used, which reduces token usage and costs. Finally, you get up-to-date information. You can update your knowledge base by uploading new documents without retraining the AI.</p>
<h2 id="heading-troubleshooting-common-issues">Troubleshooting Common Issues</h2>
<h3 id="heading-storage-rls-error-when-uploading">"Storage RLS error" when uploading</h3>
<p>This means your <code>SUPABASE_SERVICE_ROLE_KEY</code> is not set or incorrect. Make sure the key is in your <code>.env.local</code> file for local development. Also make sure you're using the service role key, not the anon key. Finally, make sure the key is correctly set in your deployment environment, such as Vercel.</p>
<h3 id="heading-failed-to-extract-text-from-file">"Failed to extract text from file"</h3>
<p>Make sure your file is a valid PDF, DOCX, or TXT file. Check that the file isn't corrupted. For PDFs, ensure they contain extractable text. Scanned PDFs with only images won't work without <a target="_blank" href="https://en.wikipedia.org/wiki/Optical_character_recognition">OCR</a>.</p>
<h3 id="heading-no-answer-generated">"No answer generated"</h3>
<p>Make sure you've uploaded at least one document. Try a different query that's more likely to match your documents. Check that embeddings were successfully created. You can verify this in your Supabase database.</p>
<h3 id="heading-vector-similarity-search-not-working">Vector similarity search not working</h3>
<p>Ensure the <code>vector</code> extension is enabled in Supabase. You can do this by running <code>CREATE EXTENSION IF NOT EXISTS vector;</code>. Verify the <code>match_documents</code> function exists in your database. You can check this in the SQL Editor. Check that embeddings are being stored correctly. They should be JSON strings in the embedding column.</p>
<h3 id="heading-slow-search-or-upload-times">Slow search or upload times</h3>
<p>Large documents take longer to process. This is because more chunks mean more embedding API calls. Consider reducing chunk size or processing documents in batches. Also check your OpenAI API rate limits.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>Now that you have a working RAG search application, you can extend it with additional features. Here are some examples of useful features you could add:</p>
<ul>
<li><p>You can add more file types by extending the text extraction to support Markdown, HTML, or other formats.</p>
</li>
<li><p>You can improve chunking by experimenting with different chunk sizes, overlap strategies, or semantic chunking.</p>
</li>
<li><p>You can add authentication to protect your documents with user authentication using Supabase Auth.</p>
</li>
<li><p>You can enhance the UI by adding features like search history, document tags, or advanced filters.</p>
</li>
<li><p>You can optimize performance by adding caching, pagination, or streaming responses.</p>
</li>
<li><p>You can add filters to allow users to search within specific documents or date ranges.</p>
</li>
<li><p>Finally, you can improve search by adding hybrid search, which combines keyword and semantic search, or reranking.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built a complete RAG search application from scratch. This application demonstrates modern web development with Next.js and TypeScript. It shows vector database operations with Supabase and pgvector. It demonstrates AI integration with OpenAI embeddings and chat completions. It includes file handling and storage with Supabase Storage. Finally, it features a production-ready user interface with Tailwind CSS.</p>
<p>The RAG pattern you've implemented is used by many production applications. These include <a target="_blank" href="https://www.freecodecamp.org/news/how-to-build-an-embeddable-ai-chatbot-widget-with-cloudflare-workers/">chatbots</a>, knowledge bases, document search systems, and AI assistants. You now have the foundation to build more advanced features on top of this.</p>
<p>The skills you've learned are highly valuable in today's AI-driven development landscape. You've learned to work with embeddings, vector databases, and the RAG pattern. You can apply these concepts to build intelligent search systems, document Q&amp;A applications, or AI-powered knowledge bases.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Embeddable AI Chatbot Widget with Cloudflare Workers ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever wanted to add an AI-powered chatbot to your website, like Intercom or Drift, without paying high monthly fees? In this tutorial, you'll learn how to build a fully functional, embeddable AI chatbot widget using Cloudflare's serverless st... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-embeddable-ai-chatbot-widget-with-cloudflare-workers/</link>
                <guid isPermaLink="false">695bde18d63e0c63c9fefea8</guid>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare-worker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI Chat Bot ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mayur Vekariya ]]>
                </dc:creator>
                <pubDate>Mon, 05 Jan 2026 15:51:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1767626158079/0b9e58c9-9299-4342-8c97-1a2de185cc60.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever wanted to add an AI-powered chatbot to your website, like Intercom or Drift, without paying high monthly fees? In this tutorial, you'll learn how to build a fully functional, embeddable AI chatbot widget using Cloudflare's serverless stack.</p>
<p>You will build a production-ready AI chatbot widget that you can embed on any website with a single script tag. It’ll be similar to Intercom or Drift – but it’s completely free and under your control.</p>
<p>By the end, you will have a chatbot that:</p>
<ul>
<li><p>Streams AI responses in real-time for a natural typing effect</p>
</li>
<li><p>Answers questions from your FAQ using RAG (Retrieval Augmented Generation)</p>
</li>
<li><p>Remembers conversations across page reloads</p>
</li>
<li><p>Supports dark and light modes</p>
</li>
<li><p>Works on any website with one line of code</p>
</li>
</ul>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-configure-wrangler">How to Configure Wrangler</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-the-backend-worker">How to Build the Backend Worker</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-tailwind-css">How to Set Up Tailwind CSS</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-the-frontend-widget">How to Build the Frontend Widget</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-the-demo-page">Create the Demo Page</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-run-it-in-your-local-system">How to Run it in Your Local System</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-deploy-to-cloudflare">How to Deploy to Cloudflare</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-embed-the-widget-on-any-website">How to Embed the Widget on Any Website</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-customize-your-chatbot">How to Customize Your Chatbot</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>A <a target="_blank" href="https://dash.cloudflare.com/sign-up">Cloudflare account</a> (the free tier works perfectly)</p>
</li>
<li><p><a target="_blank" href="https://nodejs.org/">Node.js</a> version 18 or higher installed on your computer</p>
</li>
<li><p>Basic knowledge of JavaScript</p>
</li>
</ul>
<p>You do not need any prior experience with Cloudflare Workers.</p>
<h2 id="heading-what-you-will-build"><strong>What You Will Build</strong></h2>
<p>Your chatbot will have two main parts:</p>
<ol>
<li><p><strong>Backend Worker</strong> (<strong>src/index.js</strong>): Handles chat requests, manages sessions, and connects to AI</p>
</li>
<li><p><strong>Frontend Widget</strong> (<strong>public/widget.js</strong>): The embeddable UI that users interact with</p>
</li>
</ol>
<p>You will use four Cloudflare services:</p>
<ul>
<li><p><strong>Workers AI</strong>: Powers the AI responses using Meta's Llama 3 model</p>
</li>
<li><p><strong>Vectorize</strong>: Stores and searches your FAQ for relevant context (this is the RAG part)</p>
</li>
<li><p><strong>KV</strong>: Persists conversation history between sessions</p>
</li>
<li><p><strong>Workers</strong>: Runs your serverless backend at the edge</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-the-project"><strong>How to Set Up the Project</strong></h2>
<p>First, create a new Cloudflare Workers project. Open your terminal and run the following command.</p>
<p>When it asks you for the programming language, select <code>javascript</code>, and when it asks, "Do you want to deploy your application?" select <code>no</code>, since we’re going to deploy at the end.</p>
<pre><code class="lang-powershell">npm create cloudflare@latest ai<span class="hljs-literal">-chatbot</span><span class="hljs-literal">-widget</span> -- -<span class="hljs-literal">-type</span>=hello<span class="hljs-literal">-world</span>
</code></pre>
<p>Navigate into your new project directory:</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">cd</span> ai<span class="hljs-literal">-chatbot</span><span class="hljs-literal">-widget</span>
</code></pre>
<p>And install the required development dependencies:</p>
<pre><code class="lang-powershell">npm install -<span class="hljs-literal">-save</span><span class="hljs-literal">-dev</span> tailwindcss autoprefixer postcss wrangler
</code></pre>
<p>Your project is now ready for development.</p>
<h2 id="heading-how-to-configure-wrangler"><strong>How to Configure Wrangler</strong></h2>
<p>Wrangler is Cloudflare's command-line tool for developing and deploying Workers. You need to configure it to use the required services.</p>
<p>A <a target="_blank" href="https://workers.cloudflare.com">Cloudflare Worker</a> is a serverless function that runs on Cloudflare's global edge network. Unlike traditional servers that run in a single location, Workers execute as close to your users as possible using more than 300 data centers worldwide. This results in faster response times and lower latency. You just write the JavaScript code, and Cloudflare takes care of all the infrastructure, scaling, and deployment.</p>
<h3 id="heading-create-resources-one-time-setup">Create Resources (One-Time Setup)</h3>
<p>The following resources are created via the Wrangler CLI (recommended for automation).</p>
<p>First, install Wrangler (if you don’t have it already):</p>
<pre><code class="lang-bash">npm install -g wrangler
</code></pre>
<p>To login, use <code>wrangler login</code>. This command will open a Cloudflare browser tab where you will need to authorize.</p>
<h3 id="heading-create-a-vectorize-index-for-rag">Create a vectorize index (for RAG):</h3>
<p>A <a target="_blank" href="https://developers.cloudflare.com/vectorize/"><strong>vectorize index</strong></a> is a vector database that lets you perform semantic search. Instead of searching for exact keyword matches (like in traditional databases), Vectorize finds content based on meaning.</p>
<p>Here's how it works: You convert your FAQ questions and answers into numerical vectors (called embeddings) using an AI model. When a user asks a question, the chatbot converts that question into a vector and finds the FAQ entries with the most similar vectors. This is the "RAG" (<a target="_blank" href="https://www.freecodecamp.org/news/retrieval-augmented-generation-rag-handbook/">Retrieval Augmented Generation</a>) technique, which augments the AI's response with relevant context from your knowledge base.</p>
<pre><code class="lang-bash">npx wrangler vectorize create faq-vectors --dimensions=768 --metric=cosine
</code></pre>
<h3 id="heading-create-kv-namespace-for-session-history">Create KV namespace (for session history):</h3>
<p><a target="_blank" href="https://developers.cloudflare.com/kv/"><strong>KV (Key-Value) storage</strong></a> is Cloudflare's globally distributed database for storing simple data. Think of it like a giant dictionary: you store data using a key (the session ID) and retrieve it later using that same key.</p>
<p>For your chatbot, KV stores each user's conversation history. When a user returns to your website, the chatbot retrieves their session from KV and remembers what they talked about before.</p>
<pre><code class="lang-javascript">npx wrangler kv namespace create CHAT_SESSIONS
</code></pre>
<p>Note the id from the output as you'll add it in the <code>wrangler.jsonc</code> file.</p>
<p>Create a file called <code>wrangler.jsonc</code> in your project root (you just need to replace <code>YOUR_KV_NAMESPACE_ID</code> with the ID that you received in the last step):</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"$schema"</span>: <span class="hljs-string">"node_modules/wrangler/config-schema.json"</span>,
  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"ai-chatbot-widget"</span>,
  <span class="hljs-attr">"main"</span>: <span class="hljs-string">"src/index.js"</span>,
  <span class="hljs-attr">"compatibility_date"</span>: <span class="hljs-string">"2025-12-23"</span>,
  <span class="hljs-attr">"observability"</span>: {
    <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">true</span>
  },
  <span class="hljs-attr">"assets"</span>: {
    <span class="hljs-attr">"directory"</span>: <span class="hljs-string">"./public"</span>,
    <span class="hljs-attr">"binding"</span>: <span class="hljs-string">"ASSETS"</span>
  },
  <span class="hljs-attr">"ai"</span>: {
    <span class="hljs-attr">"binding"</span>: <span class="hljs-string">"AI"</span>
  },
  <span class="hljs-attr">"vectorize"</span>: [
    {
      <span class="hljs-attr">"binding"</span>: <span class="hljs-string">"VECTORIZE"</span>,
      <span class="hljs-attr">"index_name"</span>: <span class="hljs-string">"faq-vectors"</span>
    }
  ],
  <span class="hljs-attr">"kv_namespaces"</span>: [
    {
      <span class="hljs-attr">"binding"</span>: <span class="hljs-string">"CHAT_SESSIONS"</span>,
      <span class="hljs-attr">"id"</span>: <span class="hljs-string">"YOUR_KV_NAMESPACE_ID"</span>
    }
  ]
}
</code></pre>
<p>This configuration file tells Wrangler which Cloudflare services your Worker needs access to.</p>
<p>Let me explain the key bindings:</p>
<ul>
<li><p><strong>ASSETS</strong>: Serves static files (like your widget JavaScript and CSS) from the <code>public</code> folder</p>
</li>
<li><p><strong>AI</strong>: Connects to Cloudflare's Workers AI for running machine learning models</p>
</li>
<li><p><strong>VECTORIZE</strong>: Links to your Vectorize index for storing and searching FAQ embeddings</p>
</li>
<li><p><strong>CHAT_SESSIONS</strong>: Connects to a KV namespace for storing conversation history</p>
</li>
</ul>
<h2 id="heading-how-to-build-the-backend-worker">How to Build the Backend Worker</h2>
<p>The backend Worker is the brain of your chatbot. It handles incoming chat messages, searches your FAQ for relevant context, sends the conversation to the AI, streams the response back to the user, and saves everything to KV for later.</p>
<p>Create the file <code>src/index.js</code> with this code:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">/** AI Chatbot Widget - Cloudflare Worker */</span>
<span class="hljs-keyword">const</span> SYS = <span class="hljs-string">`You are a helpful customer support assistant. Be friendly, professional, and concise. Use the FAQ context to give accurate answers. If you don't know something, say so.`</span>;
<span class="hljs-keyword">const</span> TTL = <span class="hljs-number">30</span>*<span class="hljs-number">24</span>*<span class="hljs-number">60</span>*<span class="hljs-number">60</span>;
<span class="hljs-keyword">const</span> cors = { <span class="hljs-string">'Access-Control-Allow-Origin'</span>: <span class="hljs-string">'*'</span> };
<span class="hljs-keyword">const</span> json = <span class="hljs-function">(<span class="hljs-params">d, s=<span class="hljs-number">200</span>, h={}</span>) =&gt;</span> <span class="hljs-keyword">new</span> Response(<span class="hljs-built_in">JSON</span>.stringify(d), { <span class="hljs-attr">status</span>: s, <span class="hljs-attr">headers</span>: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span>, ...cors, ...h } });
<span class="hljs-keyword">const</span> cookie = <span class="hljs-function"><span class="hljs-params">r</span> =&gt;</span> r.headers.get(<span class="hljs-string">'Cookie'</span>)?.match(<span class="hljs-regexp">/chatbot_session=([^;]+)/</span>)?.[<span class="hljs-number">1</span>];

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">faq</span>(<span class="hljs-params">env, q</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> e = <span class="hljs-keyword">await</span> env.AI.run(<span class="hljs-string">'@cf/baai/bge-base-en-v1.5'</span>, { <span class="hljs-attr">text</span>: [q] });
    <span class="hljs-keyword">if</span> (!e.data) <span class="hljs-keyword">return</span> <span class="hljs-string">''</span>;
    <span class="hljs-keyword">const</span> r = <span class="hljs-keyword">await</span> env.VECTORIZE.query(e.data[<span class="hljs-number">0</span>], { <span class="hljs-attr">topK</span>: <span class="hljs-number">3</span>, <span class="hljs-attr">returnMetadata</span>: <span class="hljs-string">'all'</span> });
    <span class="hljs-keyword">return</span> r.matches.map(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> <span class="hljs-string">`Q: <span class="hljs-subst">${m.metadata?.question}</span>\nA: <span class="hljs-subst">${m.metadata?.answer}</span>`</span>).join(<span class="hljs-string">'\n\n'</span>);
  } <span class="hljs-keyword">catch</span> { <span class="hljs-keyword">return</span> <span class="hljs-string">''</span>; }
}

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">chat</span>(<span class="hljs-params">req, env</span>) </span>{
  <span class="hljs-keyword">if</span> (req.method !== <span class="hljs-string">'POST'</span>) <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Response(<span class="hljs-string">'Method not allowed'</span>, { <span class="hljs-attr">status</span>: <span class="hljs-number">405</span> });
  <span class="hljs-keyword">const</span> { message } = <span class="hljs-keyword">await</span> req.json();
  <span class="hljs-keyword">if</span> (!message?.trim()) <span class="hljs-keyword">return</span> json({ <span class="hljs-attr">error</span>: <span class="hljs-string">'Message required'</span> }, <span class="hljs-number">400</span>);
  <span class="hljs-keyword">let</span> sid = cookie(req), isNew = !sid;
  <span class="hljs-keyword">let</span> sess = sid ? <span class="hljs-keyword">await</span> env.CHAT_SESSIONS.get(sid, <span class="hljs-string">'json'</span>) : <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">if</span> (!sess) { sid = <span class="hljs-string">'sess_'</span> + crypto.randomUUID(); sess = { <span class="hljs-attr">id</span>: sid, <span class="hljs-attr">messages</span>: [], <span class="hljs-attr">createdAt</span>: <span class="hljs-built_in">Date</span>.now(), <span class="hljs-attr">updatedAt</span>: <span class="hljs-built_in">Date</span>.now() }; isNew = <span class="hljs-literal">true</span>; }
  sess.messages.push({ <span class="hljs-attr">role</span>: <span class="hljs-string">'user'</span>, <span class="hljs-attr">content</span>: message.trim(), <span class="hljs-attr">timestamp</span>: <span class="hljs-built_in">Date</span>.now() });
  <span class="hljs-keyword">const</span> ctx = <span class="hljs-keyword">await</span> faq(env, message);
  <span class="hljs-keyword">const</span> msgs = [{ <span class="hljs-attr">role</span>: <span class="hljs-string">'system'</span>, <span class="hljs-attr">content</span>: SYS + (ctx ? <span class="hljs-string">`\n\nFAQ:\n<span class="hljs-subst">${ctx}</span>`</span> : <span class="hljs-string">''</span>) }, ...sess.messages.slice(<span class="hljs-number">-10</span>).map(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> ({ <span class="hljs-attr">role</span>: m.role, <span class="hljs-attr">content</span>: m.content }))];
  <span class="hljs-keyword">const</span> stream = <span class="hljs-keyword">await</span> env.AI.run(<span class="hljs-string">'@cf/meta/llama-3-8b-instruct'</span>, { <span class="hljs-attr">messages</span>: msgs, <span class="hljs-attr">stream</span>: <span class="hljs-literal">true</span> });
  <span class="hljs-keyword">let</span> full = <span class="hljs-string">''</span>;
  <span class="hljs-keyword">const</span> { readable, writable } = <span class="hljs-keyword">new</span> TransformStream({
    transform(chunk, ctrl) {
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> ln <span class="hljs-keyword">of</span> <span class="hljs-keyword">new</span> TextDecoder().decode(chunk).split(<span class="hljs-string">'\n'</span>))
        <span class="hljs-keyword">if</span> (ln.startsWith(<span class="hljs-string">'data: '</span>) &amp;&amp; ln.slice(<span class="hljs-number">6</span>) !== <span class="hljs-string">'[DONE]'</span>) <span class="hljs-keyword">try</span> { full += <span class="hljs-built_in">JSON</span>.parse(ln.slice(<span class="hljs-number">6</span>)).response || <span class="hljs-string">''</span>; } <span class="hljs-keyword">catch</span> {}
      ctrl.enqueue(chunk);
    },
    <span class="hljs-keyword">async</span> flush() {
      <span class="hljs-keyword">if</span> (full) { sess.messages.push({ <span class="hljs-attr">role</span>: <span class="hljs-string">'assistant'</span>, <span class="hljs-attr">content</span>: full, <span class="hljs-attr">timestamp</span>: <span class="hljs-built_in">Date</span>.now() }); sess.updatedAt = <span class="hljs-built_in">Date</span>.now(); <span class="hljs-keyword">await</span> env.CHAT_SESSIONS.put(sid, <span class="hljs-built_in">JSON</span>.stringify(sess), { <span class="hljs-attr">expirationTtl</span>: TTL }); }
    }
  });
  stream.pipeTo(writable);
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Response(readable, { <span class="hljs-attr">headers</span>: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'text/event-stream'</span>, <span class="hljs-string">'Cache-Control'</span>: <span class="hljs-string">'no-cache'</span>, ...cors, ...(isNew ? { <span class="hljs-string">'Set-Cookie'</span>: <span class="hljs-string">`chatbot_session=<span class="hljs-subst">${sid}</span>; Path=/; HttpOnly; SameSite=Lax; Max-Age=<span class="hljs-subst">${TTL}</span>`</span> } : {}) } });
}

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">seed</span>(<span class="hljs-params">req, env</span>) </span>{
  <span class="hljs-keyword">if</span> (req.method !== <span class="hljs-string">'POST'</span>) <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Response(<span class="hljs-string">'Method not allowed'</span>, { <span class="hljs-attr">status</span>: <span class="hljs-number">405</span> });
  <span class="hljs-keyword">const</span> faqs = [
    [<span class="hljs-string">'How long does shipping take?'</span>, <span class="hljs-string">'Standard 5-7 days, Express 2-3 days, Same-day in select areas.'</span>],
    [<span class="hljs-string">'What is your return policy?'</span>, <span class="hljs-string">'30-day returns for unused items. Electronics 15 days if defective.'</span>],
    [<span class="hljs-string">'Do you offer free shipping?'</span>, <span class="hljs-string">'Yes! Orders over $50 get free standard shipping.'</span>],
    [<span class="hljs-string">'How can I track my order?'</span>, <span class="hljs-string">'Check your email for tracking or log into your account.'</span>],
    [<span class="hljs-string">'What payment methods do you accept?'</span>, <span class="hljs-string">'Visa, Mastercard, Amex, PayPal, Apple Pay, Google Pay.'</span>],
    [<span class="hljs-string">'Do you have a warranty?'</span>, <span class="hljs-string">'All products have manufacturer warranty. Extended plans available.'</span>],
    [<span class="hljs-string">'Can I cancel my order?'</span>, <span class="hljs-string">'Within 1 hour if not processed. Otherwise return after delivery.'</span>],
    [<span class="hljs-string">'Do you ship internationally?'</span>, <span class="hljs-string">'Yes, 50+ countries. 7-14 days. Duties paid by customer.'</span>],
  ];
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> vecs = <span class="hljs-keyword">await</span> <span class="hljs-built_in">Promise</span>.all(faqs.map(<span class="hljs-keyword">async</span> ([q,a], i) =&gt; {
      <span class="hljs-keyword">const</span> e = <span class="hljs-keyword">await</span> env.AI.run(<span class="hljs-string">'@cf/baai/bge-base-en-v1.5'</span>, { <span class="hljs-attr">text</span>: [q+<span class="hljs-string">' '</span>+a] });
      <span class="hljs-keyword">return</span> { <span class="hljs-attr">id</span>: <span class="hljs-string">`faq-<span class="hljs-subst">${i+<span class="hljs-number">1</span>}</span>`</span>, <span class="hljs-attr">values</span>: e.data?.[<span class="hljs-number">0</span>] || [], <span class="hljs-attr">metadata</span>: { <span class="hljs-attr">question</span>: q, <span class="hljs-attr">answer</span>: a } };
    }));
    <span class="hljs-keyword">await</span> env.VECTORIZE.upsert(vecs);
    <span class="hljs-keyword">return</span> json({ <span class="hljs-attr">success</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">count</span>: faqs.length });
  } <span class="hljs-keyword">catch</span> { <span class="hljs-keyword">return</span> json({ <span class="hljs-attr">error</span>: <span class="hljs-string">'Seed failed'</span> }, <span class="hljs-number">500</span>); }
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> {
  <span class="hljs-keyword">async</span> fetch(req, env) {
    <span class="hljs-keyword">const</span> p = <span class="hljs-keyword">new</span> URL(req.url).pathname;
    <span class="hljs-keyword">if</span> (req.method === <span class="hljs-string">'OPTIONS'</span>) <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Response(<span class="hljs-literal">null</span>, { <span class="hljs-attr">headers</span>: { ...cors, <span class="hljs-string">'Access-Control-Allow-Methods'</span>: <span class="hljs-string">'GET,POST,OPTIONS'</span>, <span class="hljs-string">'Access-Control-Allow-Headers'</span>: <span class="hljs-string">'Content-Type'</span> } });
    <span class="hljs-keyword">if</span> (p === <span class="hljs-string">'/api/chat'</span>) <span class="hljs-keyword">return</span> chat(req, env);
    <span class="hljs-keyword">if</span> (p === <span class="hljs-string">'/api/history'</span>) { <span class="hljs-keyword">const</span> s = cookie(req); <span class="hljs-keyword">return</span> json({ <span class="hljs-attr">messages</span>: s ? (<span class="hljs-keyword">await</span> env.CHAT_SESSIONS.get(s, <span class="hljs-string">'json'</span>))?.messages || [] : [] }); }
    <span class="hljs-keyword">if</span> (p === <span class="hljs-string">'/api/seed'</span>) <span class="hljs-keyword">return</span> seed(req, env);
    <span class="hljs-keyword">if</span> (p === <span class="hljs-string">'/api/health'</span>) <span class="hljs-keyword">return</span> json({ <span class="hljs-attr">status</span>: <span class="hljs-string">'ok'</span> });
    <span class="hljs-keyword">return</span> env.ASSETS.fetch(req);
  }
};
</code></pre>
<p>Let me break down the key parts of this code:</p>
<ul>
<li><p><strong>Session management</strong>: The <code>cookie</code> function extracts the session ID from the user's browser cookies. When a user first chats, the Worker generates a unique session ID, stores it in an HTTP-only cookie, and saves the conversation history to KV. On subsequent visits, the Worker retrieves the session and continues the conversation.</p>
<p>  <strong>RAG with Vectorize</strong>: The <code>faq</code> function implements RAG. It converts the user's question into a vector embedding using the BGE model, then queries Vectorize for the three most similar FAQ entries. This relevant context is added to the AI prompt, helping the AI give accurate, grounded answers instead of making things up.</p>
<p>  <strong>Streaming responses</strong>: The <code>chat</code> function uses a <code>TransformStream</code> to process the AI response as it streams. Each token is passed through to the client immediately, creating a natural typing effect. When the stream ends, the complete response is saved to KV.</p>
<p>  <strong>Seeding FAQs</strong>: The <code>seed</code> function populates your FAQ database. It converts each question-answer pair into a vector embedding and stores it in Vectorize. You only need to call this once after deploying.</p>
</li>
</ul>
<p>Now that your backend is ready, let's build the frontend. But first, you need to set up Tailwind CSS to style your widget.</p>
<h2 id="heading-how-to-set-up-tailwind-css"><strong>How to Set Up Tailwind CSS</strong></h2>
<p>Your chatbot widget needs to look polished and professional. To achieve this, you will use <a target="_blank" href="https://tailwindcss.com/">Tailwind CSS</a> which is a utility-first CSS framework that lets you style elements directly in your HTML using small, single-purpose classes like <code>bg-black</code>, <code>rounded-full</code>, and <code>shadow-lg</code>.</p>
<p>Why Tailwind? Well, traditional CSS requires you to write separate stylesheets and invent class names. Tailwind eliminates this overhead by providing pre-built utility classes. This is especially useful for an embeddable widget because all the styles are self-contained and won't conflict with the host website's CSS.</p>
<p>Create the file <code>tailwind.config.js</code> in your project root:</p>
<pre><code class="lang-javascript">tail<span class="hljs-comment">/** <span class="hljs-doctag">@type <span class="hljs-type">{import('tailwindcss').Config}</span> </span>*/</span>
<span class="hljs-built_in">module</span>.exports = {
  <span class="hljs-attr">content</span>: [<span class="hljs-string">'./public/**/*.{html,js}'</span>],
  <span class="hljs-attr">darkMode</span>: <span class="hljs-string">'class'</span>,
  <span class="hljs-attr">theme</span>: { <span class="hljs-attr">extend</span>: {} },
  <span class="hljs-attr">plugins</span>: []
};
</code></pre>
<p>This configuration tells Tailwind to scan all HTML and JavaScript files in the <code>public</code> folder for class names. The <code>darkMode: 'class'</code> setting enables dark mode toggling by adding a <code>dark</code> class to the widget container.</p>
<p>Create the source CSS file at <code>src/input.css</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-meta">@tailwind</span> base;
<span class="hljs-meta">@tailwind</span> components;src/input.css;
<span class="hljs-meta">@tailwind</span> utilities;
</code></pre>
<p>This file imports Tailwind's base styles, component classes, and utility classes. When you build, Tailwind will scan your code and generate a minimal CSS file containing only the classes you actually use.</p>
<p>Update your package.json with build scripts:</p>
<pre><code class="lang-json">{
    <span class="hljs-attr">"name"</span>: <span class="hljs-string">"ai-chatbot-widget"</span>,
    <span class="hljs-attr">"version"</span>: <span class="hljs-string">"1.0.0"</span>,
    <span class="hljs-attr">"private"</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">"scripts"</span>: {
        <span class="hljs-attr">"build:css"</span>: <span class="hljs-string">"npx tailwindcss -i ./src/input.css -o ./public/styles.css --minify"</span>,
        <span class="hljs-attr">"deploy"</span>: <span class="hljs-string">"npm run build:css &amp;&amp; wrangler deploy"</span>,
        <span class="hljs-attr">"dev"</span>: <span class="hljs-string">"npm run build:css &amp;&amp; wrangler dev"</span>
    },
    <span class="hljs-attr">"devDependencies"</span>: {
        <span class="hljs-attr">"autoprefixer"</span>: <span class="hljs-string">"^10.4.23"</span>,
        <span class="hljs-attr">"postcss"</span>: <span class="hljs-string">"^8.5.6"</span>,
        <span class="hljs-attr">"tailwindcss"</span>: <span class="hljs-string">"^3.4.19"</span>,
        <span class="hljs-attr">"wrangler"</span>: <span class="hljs-string">"^4.56.0"</span>
    }
}
</code></pre>
<p>The <code>build:css</code> script compiles and minifies your Tailwind CSS. The <code>deploy</code> and <code>dev</code> scripts automatically build the CSS before starting the development server or deploying.</p>
<p>With styling ready to go, let's build the widget that users will actually interact with.</p>
<h2 id="heading-how-to-build-the-frontend-widget"><strong>How to Build the Frontend Widget</strong></h2>
<p>The frontend widget is a self-contained JavaScript file that creates the entire chat interface. When someone adds your script to their website, it automatically creates the chat bubble button, the chat window, and handles all the interactive functionality.</p>
<p>Create the file <code>public/widget.js</code>:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">/**
 * AI Chatbot Widget - Embeddable Script
 * Usage: &lt;script src="https://your-domain.com/widget.js"&gt;&lt;/script&gt;
 */</span>
(<span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) </span>{
<span class="hljs-meta">  'use strict'</span>;
  <span class="hljs-keyword">const</span> C = {
    <span class="hljs-attr">u</span>: <span class="hljs-built_in">window</span>.CHATBOT_BASE_URL || <span class="hljs-string">''</span>,
    <span class="hljs-attr">t</span>: <span class="hljs-built_in">window</span>.CHATBOT_TITLE || <span class="hljs-string">'AI Assistant'</span>,
    <span class="hljs-attr">p</span>: <span class="hljs-built_in">window</span>.CHATBOT_PLACEHOLDER || <span class="hljs-string">'Message...'</span>,
    <span class="hljs-attr">g</span>: <span class="hljs-built_in">window</span>.CHATBOT_GREETING || <span class="hljs-string">'👋 Hi! How can I help you today?'</span>
  };
  <span class="hljs-keyword">let</span> open = <span class="hljs-number">0</span>, msgs = [], typing = <span class="hljs-number">0</span>, menu = <span class="hljs-number">0</span>;
  <span class="hljs-keyword">let</span> dark = matchMedia(<span class="hljs-string">'(prefers-color-scheme:dark)'</span>).matches;
  <span class="hljs-keyword">const</span> $ = <span class="hljs-function"><span class="hljs-params">id</span> =&gt;</span> <span class="hljs-built_in">document</span>.getElementById(id);
  <span class="hljs-keyword">const</span> tog = <span class="hljs-function">(<span class="hljs-params">e, c, on</span>) =&gt;</span> e.classList.toggle(c, on);
  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">init</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">const</span> l = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">'link'</span>);
    l.rel = <span class="hljs-string">'stylesheet'</span>;
    l.href = C.u + <span class="hljs-string">'/styles.css'</span>;
    <span class="hljs-built_in">document</span>.head.appendChild(l);
    <span class="hljs-keyword">const</span> d = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">'div'</span>);
    d.id = <span class="hljs-string">'cb'</span>;
    d.innerHTML = <span class="hljs-string">`
      &lt;button id="cb-btn" class="fixed bottom-6 right-6 w-14 h-14 bg-black rounded-full shadow-2xl flex items-center justify-center cursor-pointer hover:scale-110 transition-all z-[99999]"&gt;
        &lt;svg id="cb-o" class="w-6 h-6 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"&gt;
          &lt;path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z"/&gt;
        &lt;/svg&gt;
        &lt;svg id="cb-x" class="w-6 h-6 text-white absolute opacity-0 scale-50 transition-all" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"&gt;
          &lt;path d="M6 18L18 6M6 6l12 12"/&gt;
        &lt;/svg&gt;
      &lt;/button&gt;
      &lt;div id="cb-w" class="fixed bottom-24 right-6 w-[400px] h-[600px] rounded-2xl shadow-2xl flex flex-col overflow-hidden z-[99999] opacity-0 scale-95 pointer-events-none transition-all origin-bottom-right bg-white dark:bg-gray-900"&gt;
        &lt;!-- Header --&gt;
        &lt;div class="flex items-center justify-between px-5 py-4 border-b bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800"&gt;
          &lt;div class="flex items-center gap-3"&gt;
            &lt;div class="w-10 h-10 bg-black rounded-full flex items-center justify-center"&gt;
              &lt;span class="text-white font-bold text-lg"&gt;C&lt;/span&gt;
            &lt;/div&gt;
            &lt;h3 class="font-semibold text-gray-900 dark:text-white"&gt;<span class="hljs-subst">${C.t}</span>&lt;/h3&gt;
          &lt;/div&gt;
          &lt;div class="relative"&gt;
            &lt;button id="cb-m" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"&gt;
              &lt;svg class="w-5 h-5 text-gray-500" viewBox="0 0 24 24" fill="currentColor"&gt;
                &lt;circle cx="12" cy="5" r="1.5"/&gt;&lt;circle cx="12" cy="12" r="1.5"/&gt;&lt;circle cx="12" cy="19" r="1.5"/&gt;
              &lt;/svg&gt;
            &lt;/button&gt;
            &lt;div id="cb-d" class="hidden absolute right-0 top-full mt-2 w-44 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 py-1 z-50"&gt;
              &lt;button id="cb-th" class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2"&gt;
                &lt;svg id="cb-s" class="w-4 h-4 hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"&gt;&lt;circle cx="12" cy="12" r="5"/&gt;&lt;/svg&gt;
                &lt;svg id="cb-n" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"&gt;&lt;path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/&gt;&lt;/svg&gt;
                &lt;span id="cb-tt"&gt;Dark Mode&lt;/span&gt;
              &lt;/button&gt;
              &lt;button id="cb-cl" class="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-2"&gt;
                &lt;svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"&gt;
                  &lt;path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/&gt;
                &lt;/svg&gt;
                Clear Chat
              &lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;!-- Messages --&gt;
        &lt;div id="cb-ms" class="flex-1 overflow-y-auto px-5 py-4 space-y-4 bg-gray-50 dark:bg-gray-950"&gt;&lt;/div&gt;
        &lt;!-- Typing Indicator --&gt;
        &lt;div id="cb-ty" class="hidden px-5 pb-2 bg-gray-50 dark:bg-gray-950"&gt;
          &lt;div class="flex items-center gap-2 text-gray-400 text-sm"&gt;
            &lt;div class="flex gap-1"&gt;
              &lt;span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce"&gt;&lt;/span&gt;
              &lt;span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay:.15s"&gt;&lt;/span&gt;
              &lt;span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay:.3s"&gt;&lt;/span&gt;
            &lt;/div&gt;
            Thinking...
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;!-- Input --&gt;
        &lt;form id="cb-f" class="flex items-center gap-3 px-4 py-4 border-t bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800"&gt;
          &lt;input id="cb-i" type="text" class="flex-1 px-4 py-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-full text-sm text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600" placeholder="<span class="hljs-subst">${C.p}</span>" autocomplete="off"/&gt;
          &lt;button type="submit" id="cb-se" class="p-3 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full disabled:opacity-50"&gt;
            &lt;svg class="w-5 h-5 text-gray-600 dark:text-gray-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"&gt;
              &lt;path d="M22 2L11 13M22 2L15 22L11 13L2 9L22 2Z"/&gt;
            &lt;/svg&gt;
          &lt;/button&gt;
        &lt;/form&gt;
      &lt;/div&gt;`</span>;
    <span class="hljs-built_in">document</span>.body.appendChild(d);
    bind();
    load();
    theme();
  }
  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">bind</span>(<span class="hljs-params"></span>) </span>{
    $(<span class="hljs-string">'cb-btn'</span>).onclick = flip;
    $(<span class="hljs-string">'cb-f'</span>).onsubmit = send;
    $(<span class="hljs-string">'cb-m'</span>).onclick = <span class="hljs-function"><span class="hljs-params">e</span> =&gt;</span> { e.stopPropagation(); menu = !menu; tog($(<span class="hljs-string">'cb-d'</span>), <span class="hljs-string">'hidden'</span>, !menu); };
    $(<span class="hljs-string">'cb-th'</span>).onclick = <span class="hljs-function">() =&gt;</span> { dark = !dark; theme(); menu = <span class="hljs-number">0</span>; tog($(<span class="hljs-string">'cb-d'</span>), <span class="hljs-string">'hidden'</span>, <span class="hljs-number">1</span>); };
    $(<span class="hljs-string">'cb-cl'</span>).onclick = <span class="hljs-function">() =&gt;</span> { msgs = []; draw(); menu = <span class="hljs-number">0</span>; tog($(<span class="hljs-string">'cb-d'</span>), <span class="hljs-string">'hidden'</span>, <span class="hljs-number">1</span>); };
    <span class="hljs-built_in">document</span>.onclick = <span class="hljs-function">() =&gt;</span> menu &amp;&amp; (menu = <span class="hljs-number">0</span>, tog($(<span class="hljs-string">'cb-d'</span>), <span class="hljs-string">'hidden'</span>, <span class="hljs-number">1</span>));
  }
  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">theme</span>(<span class="hljs-params"></span>) </span>{
    tog($(<span class="hljs-string">'cb'</span>), <span class="hljs-string">'dark'</span>, dark);
    $(<span class="hljs-string">'cb-tt'</span>).textContent = dark ? <span class="hljs-string">'Light Mode'</span> : <span class="hljs-string">'Dark Mode'</span>;
    tog($(<span class="hljs-string">'cb-s'</span>), <span class="hljs-string">'hidden'</span>, !dark);
    tog($(<span class="hljs-string">'cb-n'</span>), <span class="hljs-string">'hidden'</span>, dark);
  }
  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">flip</span>(<span class="hljs-params"></span>) </span>{
    open = !open;
    <span class="hljs-keyword">const</span> w = $(<span class="hljs-string">'cb-w'</span>), o = $(<span class="hljs-string">'cb-o'</span>), x = $(<span class="hljs-string">'cb-x'</span>);
    tog(w, <span class="hljs-string">'opacity-0'</span>, !open);
    tog(w, <span class="hljs-string">'scale-95'</span>, !open);
    tog(w, <span class="hljs-string">'pointer-events-none'</span>, !open);
    tog(w, <span class="hljs-string">'opacity-100'</span>, open);
    tog(w, <span class="hljs-string">'scale-100'</span>, open);
    tog(o, <span class="hljs-string">'opacity-0'</span>, open);
    tog(o, <span class="hljs-string">'scale-50'</span>, open);
    tog(x, <span class="hljs-string">'opacity-0'</span>, !open);
    tog(x, <span class="hljs-string">'scale-50'</span>, !open);
    tog(x, <span class="hljs-string">'opacity-100'</span>, open);
    tog(x, <span class="hljs-string">'scale-100'</span>, open);
    <span class="hljs-keyword">if</span> (open) {
      $(<span class="hljs-string">'cb-i'</span>).focus();
      <span class="hljs-keyword">if</span> (!msgs.length) add(<span class="hljs-string">'assistant'</span>, C.g);
    }
  }
  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">add</span>(<span class="hljs-params">r, c</span>) </span>{
    msgs.push({ <span class="hljs-attr">role</span>: r, <span class="hljs-attr">content</span>: c });
    draw();
  }
  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">esc</span>(<span class="hljs-params">t</span>) </span>{
    <span class="hljs-keyword">const</span> d = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">'div'</span>);
    d.textContent = t;
    <span class="hljs-keyword">return</span> d.innerHTML.replace(<span class="hljs-regexp">/\n/g</span>, <span class="hljs-string">'&lt;br&gt;'</span>);
  }
  <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">draw</span>(<span class="hljs-params"></span>) </span>{
    $(<span class="hljs-string">'cb-ms'</span>).innerHTML = msgs.map(<span class="hljs-function">(<span class="hljs-params">m, i</span>) =&gt;</span> m.role === <span class="hljs-string">'user'</span>
      ? <span class="hljs-string">`&lt;div class="flex justify-end"&gt;
          &lt;div class="bg-black text-white rounded-2xl rounded-br-md px-4 py-3 max-w-[85%]"&gt;
            &lt;div id="m<span class="hljs-subst">${i}</span>" class="text-sm whitespace-pre-wrap"&gt;<span class="hljs-subst">${esc(m.content)}</span>&lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;`</span>
      : <span class="hljs-string">`&lt;div class="flex justify-start"&gt;
          &lt;div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-2xl rounded-bl-md px-4 py-3 max-w-[85%] border border-gray-200 dark:border-gray-700 shadow-sm"&gt;
            &lt;div class="flex items-center gap-2 mb-2"&gt;
              &lt;div class="w-6 h-6 bg-black rounded-full flex items-center justify-center"&gt;
                &lt;span class="text-white font-bold text-xs"&gt;C&lt;/span&gt;
              &lt;/div&gt;
              &lt;span class="text-sm font-medium text-gray-700 dark:text-gray-300"&gt;<span class="hljs-subst">${C.t}</span>&lt;/span&gt;
            &lt;/div&gt;
            &lt;div id="m<span class="hljs-subst">${i}</span>" class="text-sm leading-relaxed whitespace-pre-wrap"&gt;<span class="hljs-subst">${esc(m.content)}</span>&lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;`</span>
    ).join(<span class="hljs-string">''</span>);
    $(<span class="hljs-string">'cb-ms'</span>).scrollTop = $(<span class="hljs-string">'cb-ms'</span>).scrollHeight;
  }
  <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">send</span>(<span class="hljs-params">e</span>) </span>{
    e.preventDefault();
    <span class="hljs-keyword">const</span> m = $(<span class="hljs-string">'cb-i'</span>).value.trim();
    <span class="hljs-keyword">if</span> (!m || typing) <span class="hljs-keyword">return</span>;
    add(<span class="hljs-string">'user'</span>, m);
    $(<span class="hljs-string">'cb-i'</span>).value = <span class="hljs-string">''</span>;
    $(<span class="hljs-string">'cb-se'</span>).disabled = <span class="hljs-number">1</span>;
    typing = <span class="hljs-number">1</span>;
    tog($(<span class="hljs-string">'cb-ty'</span>), <span class="hljs-string">'hidden'</span>, <span class="hljs-number">0</span>);
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> r = <span class="hljs-keyword">await</span> fetch(C.u + <span class="hljs-string">'/api/chat'</span>, {
        <span class="hljs-attr">method</span>: <span class="hljs-string">'POST'</span>,
        <span class="hljs-attr">headers</span>: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span> },
        <span class="hljs-attr">body</span>: <span class="hljs-built_in">JSON</span>.stringify({ <span class="hljs-attr">message</span>: m }),
        <span class="hljs-attr">credentials</span>: <span class="hljs-string">'include'</span>
      });
      <span class="hljs-keyword">if</span> (!r.ok) <span class="hljs-keyword">throw</span> <span class="hljs-number">0</span>;
      <span class="hljs-keyword">const</span> rd = r.body.getReader();
      <span class="hljs-keyword">const</span> dc = <span class="hljs-keyword">new</span> TextDecoder();
      <span class="hljs-keyword">let</span> t = <span class="hljs-string">''</span>, idx = <span class="hljs-literal">null</span>;
      <span class="hljs-keyword">while</span> (<span class="hljs-number">1</span>) {
        <span class="hljs-keyword">const</span> { done, value } = <span class="hljs-keyword">await</span> rd.read();
        <span class="hljs-keyword">if</span> (done) <span class="hljs-keyword">break</span>;
        <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> ln <span class="hljs-keyword">of</span> dc.decode(value, { <span class="hljs-attr">stream</span>: <span class="hljs-number">1</span> }).split(<span class="hljs-string">'\n'</span>)) {
          <span class="hljs-keyword">if</span> (!ln.startsWith(<span class="hljs-string">'data: '</span>)) <span class="hljs-keyword">continue</span>;
          <span class="hljs-keyword">const</span> d = ln.slice(<span class="hljs-number">6</span>);
          <span class="hljs-keyword">if</span> (d === <span class="hljs-string">'[DONE]'</span>) <span class="hljs-keyword">continue</span>;
          <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">const</span> p = <span class="hljs-built_in">JSON</span>.parse(d);
            <span class="hljs-keyword">if</span> (p.response) {
              t += p.response;
              <span class="hljs-keyword">if</span> (idx === <span class="hljs-literal">null</span>) {
                tog($(<span class="hljs-string">'cb-ty'</span>), <span class="hljs-string">'hidden'</span>, <span class="hljs-number">1</span>);
                typing = <span class="hljs-number">0</span>;
                msgs.push({ <span class="hljs-attr">role</span>: <span class="hljs-string">'assistant'</span>, <span class="hljs-attr">content</span>: t });
                idx = msgs.length - <span class="hljs-number">1</span>;
                draw();
              } <span class="hljs-keyword">else</span> {
                msgs[idx].content = t;
                <span class="hljs-keyword">const</span> el = $(<span class="hljs-string">'m'</span> + idx);
                <span class="hljs-keyword">if</span> (el) el.innerHTML = esc(t);
              }
              $(<span class="hljs-string">'cb-ms'</span>).scrollTop = $(<span class="hljs-string">'cb-ms'</span>).scrollHeight;
            }
          } <span class="hljs-keyword">catch</span> {}
        }
      }
    } <span class="hljs-keyword">catch</span> {
      tog($(<span class="hljs-string">'cb-ty'</span>), <span class="hljs-string">'hidden'</span>, <span class="hljs-number">1</span>);
      typing = <span class="hljs-number">0</span>;
      add(<span class="hljs-string">'assistant'</span>, <span class="hljs-string">'Sorry, an error occurred.'</span>);
    } <span class="hljs-keyword">finally</span> {
      $(<span class="hljs-string">'cb-se'</span>).disabled = <span class="hljs-number">0</span>;
      typing = <span class="hljs-number">0</span>;
      tog($(<span class="hljs-string">'cb-ty'</span>), <span class="hljs-string">'hidden'</span>, <span class="hljs-number">1</span>);
    }
  }
  <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">load</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> r = <span class="hljs-keyword">await</span> fetch(C.u + <span class="hljs-string">'/api/history'</span>, { <span class="hljs-attr">credentials</span>: <span class="hljs-string">'include'</span> });
      <span class="hljs-keyword">if</span> (r.ok) {
        <span class="hljs-keyword">const</span> d = <span class="hljs-keyword">await</span> r.json();
        <span class="hljs-keyword">if</span> (d.messages?.length) {
          msgs = d.messages;
          draw();
        }
      }
    } <span class="hljs-keyword">catch</span> {}
  }
  <span class="hljs-built_in">document</span>.readyState === <span class="hljs-string">'loading'</span>
    ? <span class="hljs-built_in">document</span>.addEventListener(<span class="hljs-string">'DOMContentLoaded'</span>, init)
    : init();
})();
</code></pre>
<p>The widget uses an IIFE (Immediately Invoked Function Expression) to avoid polluting the global namespace. Here are the key functions:</p>
<ul>
<li><p><strong>init()</strong>: Creates the widget HTML and injects it into the page</p>
</li>
<li><p><strong>bind()</strong>: Sets up all event listeners</p>
</li>
<li><p><strong>theme()</strong>: Toggles dark/light mode</p>
</li>
<li><p><strong>flip()</strong>: Opens and closes the chat window with animations</p>
</li>
<li><p><strong>draw()</strong>: Renders all messages</p>
</li>
<li><p><strong>send()</strong>: Handles message submission with streaming</p>
</li>
<li><p><strong>load()</strong>: Loads chat history from the server</p>
</li>
</ul>
<p>The streaming handler in <code>send()</code> is particularly important. It reads the AI response chunk by chunk and updates the UI as each token arrives. Instead of re-rendering the entire message list on each token (which would cause visual flashing), it updates only the content of the current message element. This creates a smooth typing effect.</p>
<p>Now you need a simple page to test everything before deploying.</p>
<h2 id="heading-create-the-demo-page">Create the Demo Page</h2>
<p>The demo page serves as a testing ground during development and a showcase for your widget. When you or your users visit your deployed Worker URL directly, they will see this page with the chatbot widget already integrated.</p>
<p>Create <code>public/index.html</code>: This demo page will be for your internal testing.</p>
<pre><code class="lang-xml"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">"en"</span>&gt;</span>

<span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"UTF-8"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"viewport"</span> <span class="hljs-attr">content</span>=<span class="hljs-string">"width=device-width, initial-scale=1.0"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>AI Chatbot Widget Demo<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">link</span> <span class="hljs-attr">rel</span>=<span class="hljs-string">"stylesheet"</span> <span class="hljs-attr">href</span>=<span class="hljs-string">"/styles.css"</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>

<span class="hljs-tag">&lt;<span class="hljs-name">body</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"min-h-screen bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center p-8"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-center text-white"</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">h1</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-4xl font-bold mb-4"</span>&gt;</span>AI Chatbot Widget<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="javascript">    <span class="hljs-built_in">window</span>.CHATBOT_BASE_URL = <span class="hljs-string">''</span>; <span class="hljs-built_in">window</span>.CHATBOT_TITLE = <span class="hljs-string">'Support'</span>; <span class="hljs-built_in">window</span>.CHATBOT_GREETING = <span class="hljs-string">"👋 Hi! I'm here to help with your questions!"</span>;  </span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"/widget.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>

<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p>This minimal page displays a title and loads the chatbot widget. The <code>CHATBOT_BASE_URL</code> is set to an empty string because when served from the same Worker, relative URLs work automatically. This is the exact same code someone would use to embed the widget on their own website, just with their own base URL instead.</p>
<p>With all the code in place, you are ready to deploy your chatbot to Cloudflare.</p>
<h2 id="heading-how-to-run-it-in-your-local-system">How to Run it in Your Local System</h2>
<p>Once all the files are added, run the command <code>npm run dev</code> to see how the chat widget looks in <code>http://localhost:8787</code>:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1766525553600/6a0dd8ed-5f2f-49ad-924d-3ef2fb143a42.png" alt="How your chatbot should look" class="image--center mx-auto" width="935" height="828" loading="lazy"></p>
<h2 id="heading-how-to-deploy-to-cloudflare"><strong>How to Deploy to Cloudflare</strong></h2>
<p>Deployment is a single command. Run:</p>
<pre><code class="lang-powershell">npm run deploy
</code></pre>
<p>This command first builds your Tailwind CSS, then deploys everything to Cloudflare. After deployment completes, you will see a URL like <code>https://ai-chatbot-widget.YOUR-SUBDOMAIN.workers.dev</code>.</p>
<p>in my case URL is <a target="_blank" href="https://ai-chatbot-widget.mv.workers.dev/">https://ai-chatbot-widget.mv.workers.dev/</a></p>
<h3 id="heading-how-to-seed-the-faq-database">How to Seed the FAQ Database</h3>
<p>Before your chatbot can answer questions from your FAQ, you need to populate the Vectorize index. Run this command (replace the URL with your actual deployment URL):</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">curl</span> <span class="hljs-literal">-X</span> POST https://ai<span class="hljs-literal">-chatbot</span><span class="hljs-literal">-widget</span>.YOUR<span class="hljs-literal">-SUBDOMAIN</span>.workers.dev/api/seed
</code></pre>
<p>You should see this response:</p>
<pre><code class="lang-json">{<span class="hljs-attr">"success"</span>:<span class="hljs-literal">true</span>,<span class="hljs-attr">"count"</span>:<span class="hljs-number">8</span>}
</code></pre>
<p>This means eight FAQ entries have been converted to vectors and stored in Vectorize. Your chatbot is now live and ready to answer questions!</p>
<p>Visit your deployment URL to test it out. Try asking about shipping, returns, or payment methods. The chatbot will respond using the FAQ context you just seeded.</p>
<p>Your chatbot is now live and ready to answer questions. You can check the Cloudflare dashboard to view the deployment. (The screenshot below is from the Cloudflare dashboard.)</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1766526373608/54e674c1-0c7c-4187-bf6f-e227763f7cef.png" alt="54e674c1-0c7c-4187-bf6f-e227763f7cef" class="image--center mx-auto" width="1008" height="651" loading="lazy"></p>
<h2 id="heading-how-to-embed-the-widget-on-any-website">How to Embed the Widget on Any Website</h2>
<p>Now for the exciting part: adding your chatbot to any website. All it takes is two script tags before the closing <code>&lt;/body&gt;</code> tag:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="javascript">
  <span class="hljs-built_in">window</span>.CHATBOT_BASE_URL = <span class="hljs-string">'https://ai-chatbot-widget.YOUR-SUBDOMAIN.workers.dev'</span>;
  <span class="hljs-built_in">window</span>.CHATBOT_TITLE = <span class="hljs-string">'Your Company'</span>;
  <span class="hljs-built_in">window</span>.CHATBOT_GREETING = <span class="hljs-string">'👋 How can I help you today?'</span>;
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://ai-chatbot-widget.YOUR-SUBDOMAIN.workers.dev/widget.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
</code></pre>
<p>Replace <code>YOUR-SUBDOMAIN</code> with your actual Cloudflare Workers subdomain.</p>
<p>Or you can also open your Cloudflare deployment URL for testing.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1766526312388/9f9a2b18-839d-44d8-b6c2-6f185c61442d.gif" alt="Testing the chatbot" class="image--center mx-auto" width="800" height="718" loading="lazy"></p>
<h3 id="heading-configuration-options">Configuration Options</h3>
<p>You can customize the widget using these variables:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Variable</strong></td><td><strong>Description</strong></td><td><strong>Default</strong></td></tr>
</thead>
<tbody>
<tr>
<td><code>CHATBOT_BASE_URL</code></td><td>Your deployed Worker URL</td><td><code>''</code> (same origin)</td></tr>
<tr>
<td><code>CHATBOT_TITLE</code></td><td>Name shown in the header</td><td><code>'AI Assistant'</code></td></tr>
<tr>
<td><code>CHATBOT_PLACEHOLDER</code></td><td>Input field placeholder</td><td><code>'Message...'</code></td></tr>
<tr>
<td><code>CHATBOT_GREETING</code></td><td>Initial greeting message</td><td><code>'👋 Hi! How can I help you today?'</code></td></tr>
</tbody>
</table>
</div><h2 id="heading-how-to-customize-your-chatbot">How to Customize Your Chatbot</h2>
<p>Your chatbot is working, but you probably want to tailor it to your specific use case. Here are the most common customizations.</p>
<h3 id="heading-how-to-add-your-own-faqs">How to Add Your Own FAQs</h3>
<p>Open <code>src/index.js</code> and find the <code>seed</code> function. Replace the sample FAQs with your own question-answer pairs:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> faqs = [
  [<span class="hljs-string">'Your question here?'</span>, <span class="hljs-string">'Your answer here.'</span>],
  [<span class="hljs-string">'Another question?'</span>, <span class="hljs-string">'Another answer.'</span>]
  <span class="hljs-comment">// Add more Q&amp;A pairs</span>
];
</code></pre>
<p>Then redeploy with <code>npm run deploy</code> and call the <code>/api/seed</code> endpoint again to update your vector database.</p>
<h3 id="heading-how-to-change-the-ai-personality">How to Change the AI Personality</h3>
<p>Edit the <code>SYS</code> constant at the top of <code>src/index.js</code>:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> SYS = <span class="hljs-string">`You are a friendly assistant for [Your Company].
You help customers with [your main services].
Always be helpful and professional.`</span>;
</code></pre>
<p>This system prompt shapes how the AI responds to users.</p>
<h3 id="heading-how-to-style-the-widget">How to Style the Widget</h3>
<p>All styles use Tailwind CSS classes in <code>widget.js</code>. To change the appearance:</p>
<ul>
<li><p><strong>Colors</strong>: Change <code>bg-black</code> to your brand color</p>
</li>
<li><p><strong>Size</strong>: Adjust <code>w-[400px] h-[600px]</code> for the chat window dimensions</p>
</li>
<li><p><strong>Position</strong>: Modify <code>bottom-6 right-6</code> for placement</p>
</li>
</ul>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>Congratulations! You have built a complete AI chatbot widget that rivals expensive SaaS solutions like Intercom and Drift. Your chatbot streams AI responses in real-time, answers questions based on your FAQ using RAG, and remembers conversations across sessions—all for free.</p>
<p>Here is a quick recap of what you built:</p>
<ul>
<li><p>A backend Worker that handles chat, sessions, and FAQ search</p>
</li>
<li><p>A frontend widget that can be embedded on any website</p>
</li>
<li><p>Integration with Workers AI for intelligent responses</p>
</li>
<li><p>Vectorize for semantic FAQ search</p>
</li>
<li><p>KV for persistent conversation history</p>
</li>
</ul>
<p>The Cloudflare stack offers generous free tiers that should cover most use cases:</p>
<ul>
<li><p><strong>Workers</strong>: 100,000 requests per day</p>
</li>
<li><p><strong>Workers AI</strong>: 10,000 neurons per day</p>
</li>
<li><p><strong>Vectorize</strong>: 5 million vector operations per month</p>
</li>
<li><p><strong>KV</strong>: 100,000 reads and 1,000 writes per day</p>
</li>
</ul>
<p>For most websites, you can run this chatbot completely free.</p>
<p>The source code for this project is available on <a target="_blank" href="https://github.com/mayur9210/ai-chatbot-widget">GitHub</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Adaptive Tic-Tac-Toe AI with Reinforcement Learning in JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ Reinforcement learning (RL) is one of the most powerful paradigms in artificial intelligence. Unlike supervised learning where you train models on labeled datasets, RL agents learn through direct interaction with their environment, receiving rewards ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-adaptive-tic-tac-toe-ai-with-reinforcement-learning-in-javascript/</link>
                <guid isPermaLink="false">68e57cd7b148e87f05670d05</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Reinforcement Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mayur Vekariya ]]>
                </dc:creator>
                <pubDate>Tue, 07 Oct 2025 20:49:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1759870150966/f65a07a6-123b-45e2-a3f2-bc099638825a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Reinforcement learning (RL) is one of the most powerful paradigms in artificial intelligence. Unlike supervised learning where you train models on labeled datasets, RL agents learn through direct interaction with their environment, receiving rewards or penalties for their actions.</p>
<p>In this tutorial, you will build a Tic-Tac-Toe AI that learns optimal strategies through Q-learning, a foundational RL algorithm. You will implement adaptive difficulty levels, visualize the learning process in real-time, and explore advanced optimization techniques.</p>
<p>By the end of this tutorial, you’ll have a production-ready web application that demonstrates practical RL concepts – all running directly in the browser with vanilla JavaScript.</p>
<h2 id="heading-what-youll-learn">What You’ll Learn</h2>
<p>In this tutorial, you’ll learn:</p>
<ul>
<li><p>Core reinforcement learning concepts including Q-learning, exploration vs exploitation, and reward shaping.</p>
</li>
<li><p>How to implement a complete Q-learning algorithm with state management.</p>
</li>
<li><p>Advanced techniques like epsilon decay and experience replay.</p>
</li>
<li><p>How to build an interactive game with HTML5 Canvas and responsive controls.</p>
</li>
<li><p>Performance optimization for real-time AI decision-making.</p>
</li>
<li><p>Visualization techniques to understand the AI's learning process.</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To get the most out of this tutorial, you should have:</p>
<ul>
<li><p>Solid understanding of JavaScript (ES6+ syntax, classes, array methods).</p>
</li>
<li><p>Familiarity with HTML5 Canvas API for graphics rendering.</p>
</li>
<li><p>Basic knowledge of algorithms and data structures.</p>
</li>
<li><p>Understanding of asynchronous JavaScript (Promises, async/await).</p>
</li>
</ul>
<p>You don’t need any prior machine learning experience, as I’ll explain all RL concepts from scratch.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-why-use-reinforcement-learning-for-game-ai">Why Use Reinforcement Learning for Game AI?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-understand-q-learning-the-foundation">How to Understand Q-Learning: The Foundation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-project-architecture-overview">Project Architecture Overview</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-the-html-interface-with-tailwind-css">How to Build the HTML Interface with Tailwind CSS</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-implement-the-q-learning-algorithm">How to Implement the Q-Learning Algorithm</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-understand-the-enhanced-features">How to Understand the Enhanced Features</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-test-your-implementation">How to Test Your Implementation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-advanced-optimizations-and-extensions">Advanced Optimizations and Extensions</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-common-pitfalls-and-solutions">Common Pitfalls and Solutions</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-extend-this-to-other-games">How to Extend This to Other Games</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-use-reinforcement-learning-for-game-ai">Why Use Reinforcement Learning for Game AI?</h2>
<p>Games provide an ideal environment for learning RL because they have:</p>
<ol>
<li><p><strong>Clear state representations</strong> – The game board at any moment</p>
</li>
<li><p><strong>Discrete action spaces</strong> – A finite set of valid moves</p>
</li>
<li><p><strong>Immediate feedback</strong> – Win, lose, or draw outcomes</p>
</li>
<li><p><strong>Deterministic rules</strong> – Consistent behavior across games</p>
</li>
</ol>
<p>Traditional game AI uses techniques like minimax with alpha-beta pruning. While effective, these approaches require you to explicitly program game strategies. RL agents, by contrast, discover optimal strategies through experience – much like humans learn through practice.</p>
<p>Tic-Tac-Toe serves as an excellent starting point because:</p>
<ul>
<li><p>The state space is manageable (5,478 unique positions)</p>
</li>
<li><p>Games are short, allowing rapid iteration</p>
</li>
<li><p>Perfect play is achievable, providing a clear success metric</p>
</li>
<li><p>The concepts scale to more complex games</p>
</li>
</ul>
<h2 id="heading-how-to-understand-q-learning-the-foundation">How to Understand Q-Learning: The Foundation</h2>
<p><a target="_blank" href="https://www.freecodecamp.org/news/an-introduction-to-q-learning-reinforcement-learning-14ac0b4493cc/">Q-learning</a> is a model-free, value-based RL algorithm. Let me break down what that means:</p>
<ul>
<li><p><strong>Model-free</strong> means that the agent doesn’t need to understand the game's rules. It learns purely from experience.</p>
</li>
<li><p><strong>Value-based</strong> means that the agent learns the "value" of each action in each state, then chooses the action with the highest value.</p>
</li>
</ul>
<h3 id="heading-core-components">Core Components</h3>
<p>There are a few key components you’ll need to understand before building this game.</p>
<p>First, we have <strong>state (s)</strong>, which here is the current game board configuration. We represent this as a 9-character string (for example, <code>"XO-X-----"</code> where <code>-</code> represents empty cells).</p>
<p>Next, we have <strong>action (a)</strong>, which is a move the AI can make. We represent this as an index from 0-8 corresponding to board positions.</p>
<p>Then there’s <strong>reward (r)</strong>, the numerical feedback from the environment:</p>
<ul>
<li><p><code>+1</code> for winning</p>
</li>
<li><p><code>-1</code> for losing</p>
</li>
<li><p><code>0</code> for draws or ongoing games</p>
</li>
</ul>
<p>We also have <strong>Q-Table</strong>, a lookup table storing Q(s,a) – the expected cumulative reward for taking action <code>a</code> in state <code>s</code>.</p>
<p>And finally, there’s <strong>policy</strong>, the strategy for choosing actions. We use an epsilon-greedy policy that balances exploration and exploitation.</p>
<h3 id="heading-the-q-learning-update-rule">The Q-Learning Update Rule</h3>
<p>The heart of Q-learning is this update formula:</p>
<pre><code class="lang-bash">Q(s,a) ← Q(s,a) + α[r + γ max Q(s<span class="hljs-string">',a'</span>) - Q(s,a)]
</code></pre>
<p>Where:</p>
<ul>
<li><p><code>α</code> (alpha) = Learning rate (0 to 1) – how much to update the Q-value</p>
</li>
<li><p><code>γ</code> (gamma) = Discount factor (0 to 1) – how much to value future rewards</p>
</li>
<li><p><code>s'</code> = Next state after taking action <code>a</code></p>
</li>
<li><p><code>max Q(s',a')</code> = Highest Q-value available in the next state.</p>
</li>
</ul>
<p>This formula implements <strong>temporal difference learning</strong>. This means it updates our estimate of Q(s,a) based on the difference between our current estimate and a better estimate using the actual reward received plus the best possible future reward.</p>
<h3 id="heading-how-exploration-vs-exploitation-works">How Exploration vs Exploitation Works</h3>
<p>A critical challenge in reinforcement learning is the "exploration vs. exploitation" trade-off. To understand why this is difficult, imagine choosing a place for dinner.</p>
<ul>
<li><p><strong>Exploitation:</strong> You could go to your favorite restaurant. You know the food is good, and you're almost guaranteed a satisfying meal. This is a safe, reliable choice that maximizes your immediate reward based on past experience.</p>
</li>
<li><p><strong>Exploration:</strong> You could try a new, unknown restaurant. It might be a disaster, or you might discover a new favorite that’s even better than your old one. This is a risky choice that provides no immediate guarantee, but it's the only way to gather new information and potentially find a better long-term strategy.</p>
</li>
</ul>
<p>The same dilemma applies to our AI. If it only exploits its current knowledge, it might get stuck using a mediocre strategy, never discovering the brilliant moves that lead to a guaranteed win. If it only explores by making random moves, it will never learn to use the good strategies it finds and will play poorly.</p>
<p>The key is to balance the two: explore enough to find optimal strategies, but exploit that knowledge to win games.</p>
<p>To achieve this balance, we use an <strong>epsilon-greedy (ϵ) strategy</strong>. It’s a simple but powerful way to manage this trade-off:</p>
<ol>
<li><p>We choose a small value for epsilon (ϵ), for example, 0.1 (which represents a 10% probability).</p>
</li>
<li><p>Before the AI makes a move, it generates a random number between 0 and 1.</p>
</li>
<li><p><strong>If the random number is less than ϵ (the 10% chance):</strong> The AI ignores its strategy and chooses a random available move. This is <strong>exploration</strong>.</p>
</li>
<li><p><strong>If the random number is greater than or equal to ϵ (the 90% chance):</strong> The AI chooses the best-known move from its Q-table.This is <strong>exploitation</strong>.</p>
</li>
</ol>
<p>This ensures the AI primarily plays to win but still dedicates a small fraction of its moves to trying new things. We will also implement <strong>epsilon decay</strong> – starting with a higher ϵ value to encourage exploration when the AI is inexperienced, and gradually lowering it as the AI learns and becomes more confident in its strategy.</p>
<h2 id="heading-project-architecture-overview">Project Architecture Overview</h2>
<p>Before you start coding, here's the structure of the application you’ll build:</p>
<pre><code class="lang-bash">tic-tac-toe-ai/
├── index.html          <span class="hljs-comment"># Game interface with Tailwind CSS</span>
└── game.js            <span class="hljs-comment"># Complete game logic and AI</span>
</code></pre>
<p>You will organize your code into two main classes in game.js:</p>
<ol>
<li><p><strong>QLearning</strong>: Implements the Q-learning algorithm.</p>
</li>
<li><p><strong>TicTacToe</strong>: Manages game state and rendering.</p>
</li>
</ol>
<h2 id="heading-how-to-build-the-html-interface-with-tailwind-css">How to Build the HTML Interface with Tailwind CSS</h2>
<p>Create an <code>index.html</code> file with Tailwind CSS CDN:</p>
<pre><code class="lang-xml"><span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">html</span> <span class="hljs-attr">lang</span>=<span class="hljs-string">"en"</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">charset</span>=<span class="hljs-string">"UTF-8"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">meta</span> <span class="hljs-attr">name</span>=<span class="hljs-string">"viewport"</span> <span class="hljs-attr">content</span>=<span class="hljs-string">"width=device-width, initial-scale=1.0"</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span>Tic-Tac-Toe AI with Q-Learning<span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://cdn.tailwindcss.com"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">body</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-gradient-to-br from-purple-600 to-purple-900 min-h-screen flex items-center justify-center p-4"</span>&gt;</span>

  <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-white rounded-3xl shadow-2xl p-8 max-w-5xl w-full"</span>&gt;</span>
    <span class="hljs-comment">&lt;!-- Header --&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-center mb-8"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h1</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-4xl font-bold text-gray-800 mb-2"</span>&gt;</span>🎮 Tic-Tac-Toe AI<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-gray-600 text-lg"</span>&gt;</span>Watch the AI learn through reinforcement learning<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

    <span class="hljs-comment">&lt;!-- Training Indicator --&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"trainingIndicator"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"hidden bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-6 rounded"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"font-semibold"</span>&gt;</span>🤖 AI is training... <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"trainingProgress"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

    <span class="hljs-comment">&lt;!-- Main Game Area --&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"grid md:grid-cols-2 gap-8"</span>&gt;</span>

      <span class="hljs-comment">&lt;!-- Canvas Section --&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"flex flex-col items-center"</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">canvas</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"gameCanvas"</span> <span class="hljs-attr">width</span>=<span class="hljs-string">"400"</span> <span class="hljs-attr">height</span>=<span class="hljs-string">"400"</span> 
                <span class="hljs-attr">class</span>=<span class="hljs-string">"border-4 border-purple-500 rounded-xl shadow-lg cursor-pointer hover:scale-[1.02] transition-transform"</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">canvas</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"gameStatus"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"mt-4 text-xl font-bold text-gray-700 min-h-[30px]"</span>&gt;</span>
          Your turn! (X)
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

      <span class="hljs-comment">&lt;!-- Controls Section --&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"space-y-6"</span>&gt;</span>

        <span class="hljs-comment">&lt;!-- Game Controls --&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-gray-50 rounded-xl p-6"</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">h3</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xl font-bold text-gray-800 mb-4"</span>&gt;</span>Game Controls<span class="hljs-tag">&lt;/<span class="hljs-name">h3</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"space-y-3"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"game.reset()"</span> 
                    <span class="hljs-attr">class</span>=<span class="hljs-string">"w-full bg-purple-600 hover:bg-purple-700 text-white font-semibold py-3 px-6 rounded-lg transition-all hover:-translate-y-0.5 shadow-md hover:shadow-lg"</span>&gt;</span>
              New Game
            <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"game.startTraining()"</span> 
                    <span class="hljs-attr">class</span>=<span class="hljs-string">"w-full bg-green-600 hover:bg-green-700 text-white font-semibold py-3 px-6 rounded-lg transition-all hover:-translate-y-0.5 shadow-md hover:shadow-lg"</span>&gt;</span>
              Train AI (1000 games)
            <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"game.resetAI()"</span> 
                    <span class="hljs-attr">class</span>=<span class="hljs-string">"w-full bg-red-600 hover:bg-red-700 text-white font-semibold py-3 px-6 rounded-lg transition-all hover:-translate-y-0.5 shadow-md hover:shadow-lg"</span>&gt;</span>
              Reset AI Memory
            <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
          <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

        <span class="hljs-comment">&lt;!-- Difficulty Selector --&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-gray-50 rounded-xl p-6"</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">h3</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xl font-bold text-gray-800 mb-4"</span>&gt;</span>Difficulty Level<span class="hljs-tag">&lt;/<span class="hljs-name">h3</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"grid grid-cols-3 gap-2"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"game.setDifficulty('beginner')"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"diffBeginner"</span>
                    <span class="hljs-attr">class</span>=<span class="hljs-string">"py-2 px-4 rounded-lg font-semibold text-sm transition-all bg-green-100 text-green-700 hover:bg-green-200"</span>&gt;</span>
              🌱 Beginner
            <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"game.setDifficulty('intermediate')"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"diffIntermediate"</span>
                    <span class="hljs-attr">class</span>=<span class="hljs-string">"py-2 px-4 rounded-lg font-semibold text-sm transition-all bg-white text-gray-700 hover:bg-gray-100 border-2 border-purple-500"</span>&gt;</span>
              🎯 Medium
            <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">button</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">"game.setDifficulty('expert')"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"diffExpert"</span>
                    <span class="hljs-attr">class</span>=<span class="hljs-string">"py-2 px-4 rounded-lg font-semibold text-sm transition-all bg-white text-gray-700 hover:bg-gray-100"</span>&gt;</span>
              🔥 Expert
            <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
          <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

        <span class="hljs-comment">&lt;!-- AI Parameters --&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-gray-50 rounded-xl p-6"</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">h3</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xl font-bold text-gray-800 mb-4"</span>&gt;</span>AI Parameters<span class="hljs-tag">&lt;/<span class="hljs-name">h3</span>&gt;</span>

          <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"space-y-4"</span>&gt;</span>
            <span class="hljs-comment">&lt;!-- Learning Rate --&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"flex justify-between items-center mb-2"</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-sm font-medium text-gray-700 flex items-center gap-1"</span>&gt;</span>
                  Learning Rate (α)
                  <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"group relative"</span>&gt;</span>
                    <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"cursor-help text-purple-500"</span>&gt;</span>ⓘ<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
                    <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"invisible group-hover:visible absolute left-0 top-6 w-64 bg-gray-900 text-white text-xs rounded-lg p-3 z-10 shadow-xl"</span>&gt;</span>
                      Controls how quickly the AI updates its knowledge. Higher values = faster learning but less stability. Recommended: 0.1-0.3
                    <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
                  <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
                <span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"learningRateValue"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-sm font-bold text-purple-600"</span>&gt;</span>0.1<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
              <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"range"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"learningRate"</span> <span class="hljs-attr">min</span>=<span class="hljs-string">"0.01"</span> <span class="hljs-attr">max</span>=<span class="hljs-string">"0.5"</span> <span class="hljs-attr">step</span>=<span class="hljs-string">"0.01"</span> <span class="hljs-attr">value</span>=<span class="hljs-string">"0.1"</span>
                     <span class="hljs-attr">class</span>=<span class="hljs-string">"w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

            <span class="hljs-comment">&lt;!-- Discount Factor --&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"flex justify-between items-center mb-2"</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-sm font-medium text-gray-700 flex items-center gap-1"</span>&gt;</span>
                  Discount Factor (γ)
                  <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"group relative"</span>&gt;</span>
                    <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"cursor-help text-purple-500"</span>&gt;</span>ⓘ<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
                    <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"invisible group-hover:visible absolute left-0 top-6 w-64 bg-gray-900 text-white text-xs rounded-lg p-3 z-10 shadow-xl"</span>&gt;</span>
                      Determines how much the AI values future rewards vs immediate rewards. Higher = more long-term thinking. Recommended: 0.85-0.95
                    <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
                  <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
                <span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"discountFactorValue"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-sm font-bold text-purple-600"</span>&gt;</span>0.9<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
              <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"range"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"discountFactor"</span> <span class="hljs-attr">min</span>=<span class="hljs-string">"0.5"</span> <span class="hljs-attr">max</span>=<span class="hljs-string">"0.99"</span> <span class="hljs-attr">step</span>=<span class="hljs-string">"0.01"</span> <span class="hljs-attr">value</span>=<span class="hljs-string">"0.9"</span>
                     <span class="hljs-attr">class</span>=<span class="hljs-string">"w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

            <span class="hljs-comment">&lt;!-- Exploration Rate --&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"flex justify-between items-center mb-2"</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">label</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-sm font-medium text-gray-700 flex items-center gap-1"</span>&gt;</span>
                  Exploration Rate (ε)
                  <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"group relative"</span>&gt;</span>
                    <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"cursor-help text-purple-500"</span>&gt;</span>ⓘ<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
                    <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"invisible group-hover:visible absolute left-0 top-6 w-64 bg-gray-900 text-white text-xs rounded-lg p-3 z-10 shadow-xl"</span>&gt;</span>
                      Chance the AI tries random moves vs using learned strategy. Higher = more experimentation. Set to 0.01 for best play after training.
                    <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
                  <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
                <span class="hljs-tag">&lt;/<span class="hljs-name">label</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"explorationRateValue"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-sm font-bold text-purple-600"</span>&gt;</span>0.1<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
              <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">input</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"range"</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"explorationRate"</span> <span class="hljs-attr">min</span>=<span class="hljs-string">"0"</span> <span class="hljs-attr">max</span>=<span class="hljs-string">"0.5"</span> <span class="hljs-attr">step</span>=<span class="hljs-string">"0.01"</span> <span class="hljs-attr">value</span>=<span class="hljs-string">"0.1"</span>
                     <span class="hljs-attr">class</span>=<span class="hljs-string">"w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
          <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

        <span class="hljs-comment">&lt;!-- Statistics --&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-gray-50 rounded-xl p-6"</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">h3</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xl font-bold text-gray-800 mb-4"</span>&gt;</span>Statistics<span class="hljs-tag">&lt;/<span class="hljs-name">h3</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"grid grid-cols-3 gap-3"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-white rounded-lg p-3 text-center shadow-sm"</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xs text-gray-600 mb-1"</span>&gt;</span>Games<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"gamesPlayed"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-2xl font-bold text-gray-800"</span>&gt;</span>0<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-white rounded-lg p-3 text-center shadow-sm"</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xs text-gray-600 mb-1"</span>&gt;</span>AI Wins<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"aiWins"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-2xl font-bold text-green-600"</span>&gt;</span>0<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-white rounded-lg p-3 text-center shadow-sm"</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xs text-gray-600 mb-1"</span>&gt;</span>You Win<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"playerWins"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-2xl font-bold text-red-600"</span>&gt;</span>0<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-white rounded-lg p-3 text-center shadow-sm"</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xs text-gray-600 mb-1"</span>&gt;</span>Draws<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"draws"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-2xl font-bold text-gray-600"</span>&gt;</span>0<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-white rounded-lg p-3 text-center shadow-sm"</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xs text-gray-600 mb-1"</span>&gt;</span>States<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"statesLearned"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-2xl font-bold text-purple-600"</span>&gt;</span>0<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"bg-white rounded-lg p-3 text-center shadow-sm"</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-xs text-gray-600 mb-1"</span>&gt;</span>Win Rate<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">id</span>=<span class="hljs-string">"winRate"</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"text-2xl font-bold text-blue-600"</span>&gt;</span>0%<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
          <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>

  <span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"game.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p>This HTML structure creates a responsive, modern interface using Tailwind CSS utility classes. The layout uses a two-column grid on medium screens and larger, with the game canvas on the left and all controls on the right. The training indicator starts hidden and only appears during AI training sessions.</p>
<p>All interactive elements (buttons, sliders) use <code>onclick</code> handlers and <code>oninput</code> events to communicate with the JavaScript game logic. The tooltip system uses CSS group hover states to show explanatory text when users hover over the info icons, helping them understand each parameter without cluttering the interface.</p>
<p>Let’s talk in a bit more detail about some key parts of the code:</p>
<ul>
<li><p><strong>Header Section</strong>: Displays the game title and subtitle to introduce users to the application.</p>
</li>
<li><p><strong>Training Indicator</strong>: A yellow banner that appears only during AI training sessions, showing progress updates every 50 games. This provides visual feedback so users know the training is in progress.</p>
</li>
<li><p><strong>Canvas Section</strong>: Contains the HTML5 Canvas element where the game board is drawn. The canvas is 400x400 pixels and styled with Tailwind classes for borders and hover effects. Below it is a status message that updates based on game state.</p>
</li>
<li><p><strong>Game Controls</strong>: Three primary buttons that let users start a new game, train the AI through 1000 self-play games, or completely reset the AI's memory (clearing the Q-table).</p>
</li>
<li><p><strong>Difficulty Selector</strong>: Three buttons for choosing AI difficulty. Beginner mode makes the AI play randomly 70% of the time, Intermediate uses Q-learning, and Expert implements perfect minimax play.</p>
</li>
<li><p><strong>AI Parameters</strong>: Three range sliders with tooltips that let users adjust the core reinforcement learning hyperparameters in real-time. The tooltips appear on hover and explain what each parameter does.</p>
</li>
<li><p><strong>Statistics Panel</strong>: A grid of six cards displaying real-time metrics including games played, wins/losses/draws, learned states, and AI win rate percentage.</p>
</li>
</ul>
<p>All interactive elements use <code>onclick</code> handlers that call methods from the <code>game</code> object defined in <code>game.js</code>.</p>
<h2 id="heading-how-to-implement-the-q-learning-algorithm">How to Implement the Q-Learning Algorithm</h2>
<p>Now, let's bring the theory to life. Create a <code>game.js</code> file. We will build this file step-by-step, but if you get stuck at any point or want to see the complete code for reference, you can find the final version <a target="_blank" href="https://github.com/mayur9210/tic-tac-toe-ai/blob/main/game.js">on <strong>GitHub</strong> here</a>.</p>
<p>Our code will be structured into two main classes: <code>QLearning</code>, which will handle the AI's "brain" and learning logic, and <code>TicTacToe</code>, which will manage the game state, rendering, and user interaction.</p>
<h3 id="heading-the-qlearning-class-the-ais-brain">The <code>QLearning</code> Class: The AI's Brain</h3>
<p>This class will contain all the logic for the <a target="_blank" href="https://github.com/mayur9210/tic-tac-toe-ai/blob/main/game.js">reinforcement learning agent</a>. Let's build it piece by piece.</p>
<h4 id="heading-1-constructor-and-q-table-management">1. Constructor and Q-Table Management</h4>
<p>First, let's set up the <code>constructor</code> and a method to access our Q-table. The Q-table will be a JavaScript <code>Map</code>, which is highly efficient for storing and retrieving key-value pairs where the key (the board state) is a string.</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// In game.js</span>

<span class="hljs-comment">// Q-Learning Agent with localStorage support</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">QLearning</span> </span>{
  <span class="hljs-keyword">constructor</span>(lr = 0.1, gamma = 0.9, epsilon = 0.1) {
    <span class="hljs-built_in">this</span>.q = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>(); <span class="hljs-comment">// Stores Q-values: { state =&gt; [q_action_0, q_action_1, ...] }</span>
    <span class="hljs-built_in">this</span>.lr = lr; <span class="hljs-comment">// Learning Rate (α)</span>
    <span class="hljs-built_in">this</span>.gamma = gamma; <span class="hljs-comment">// Discount Factor (γ)</span>
    <span class="hljs-built_in">this</span>.epsilon = epsilon; <span class="hljs-comment">// Exploration Rate (ε)</span>
    <span class="hljs-built_in">this</span>.difficulty = <span class="hljs-string">'intermediate'</span>;
  }

  getQ(state) {
    <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">this</span>.q.has(state)) {
      <span class="hljs-built_in">this</span>.q.set(state, <span class="hljs-built_in">Array</span>(<span class="hljs-number">9</span>).fill(<span class="hljs-number">0</span>));
    }
    <span class="hljs-keyword">return</span> <span class="hljs-built_in">this</span>.q.get(state);
  }
</code></pre>
<ul>
<li><p>The <code>constructor</code> initializes our three key hyperparameters (α, γ, ϵ) and the Q-table itself.</p>
</li>
<li><p><code>getQ(state)</code> is a crucial helper function. It safely retrieves the array of Q-values for a given board state. If the AI has never seen this state before, it creates a new entry in the map with an array of nine zeros, representing an initial Q-value of 0 for each possible move.</p>
</li>
</ul>
<h4 id="heading-2-choosing-an-action-the-epsilon-greedy-strategy">2. Choosing an Action (The Epsilon-Greedy Strategy)</h4>
<p>Next, we'll implement the <code>getAction</code> method. This is where the AI decides which move to make, incorporating our difficulty levels and the epsilon-greedy strategy.</p>
<pre><code class="lang-javascript">  getAction(state, available) {
    <span class="hljs-comment">// Difficulty-based behavior</span>
    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">this</span>.difficulty === <span class="hljs-string">'beginner'</span>) {
      <span class="hljs-comment">// 70% random moves for beginner</span>
      <span class="hljs-keyword">if</span> (<span class="hljs-built_in">Math</span>.random() &lt; <span class="hljs-number">0.7</span>) {
        <span class="hljs-keyword">return</span> available[~~(<span class="hljs-built_in">Math</span>.random() * available.length)];
      }
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (<span class="hljs-built_in">this</span>.difficulty === <span class="hljs-string">'expert'</span>) {
      <span class="hljs-comment">// Use minimax for perfect play</span>
      <span class="hljs-keyword">return</span> <span class="hljs-built_in">this</span>.getMinimaxAction(state, available);
    }

    <span class="hljs-comment">// Intermediate: epsilon-greedy</span>
    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">Math</span>.random() &lt; <span class="hljs-built_in">this</span>.epsilon) {
      <span class="hljs-keyword">return</span> available[~~(<span class="hljs-built_in">Math</span>.random() * available.length)];
    }
    <span class="hljs-keyword">const</span> q = <span class="hljs-built_in">this</span>.getQ(state);
    <span class="hljs-keyword">return</span> available.reduce(<span class="hljs-function">(<span class="hljs-params">best, a</span>) =&gt;</span> q[a] &gt; q[best] ? a : best, available[<span class="hljs-number">0</span>]);
  }
</code></pre>
<ul>
<li><p>The logic first checks the difficulty. 'Beginner' is mostly random, while 'Expert' defers to a separate, perfect-play algorithm.</p>
</li>
<li><p>For the 'Intermediate' level, it implements the epsilon-greedy logic. With probability ϵ, it explores (chooses a random move). Otherwise, it exploits (chooses the best-known move from the Q-table).</p>
</li>
</ul>
<h4 id="heading-3-the-learning-rule">3. The Learning Rule</h4>
<p>The <code>update</code> method is the heart of the algorithm. It's the direct implementation of the Q-learning formula we discussed earlier.</p>
<p><em>Q(s, a) ← Q(s, a) + α [r + γ max(a') Q(s', a') − Q(s, a)]</em></p>
<pre><code class="lang-javascript">  update(s, a, r, s2, available2) {
    <span class="hljs-keyword">const</span> q = <span class="hljs-built_in">this</span>.getQ(s);
    <span class="hljs-keyword">const</span> maxQ2 = available2.length ? <span class="hljs-built_in">Math</span>.max(...available2.map(<span class="hljs-function"><span class="hljs-params">a_prime</span> =&gt;</span> <span class="hljs-built_in">this</span>.getQ(s2)[a_prime])) : <span class="hljs-number">0</span>;
    q[a] += <span class="hljs-built_in">this</span>.lr * (r + <span class="hljs-built_in">this</span>.gamma * maxQ2 - q[a]);
  }
</code></pre>
<ul>
<li><p><code>maxQ2</code> calculates the <code>max Q(s',a')</code> part of the formula – the best possible Q-value the AI can get from its next move.</p>
</li>
<li><p>The final line is a direct translation of the formula, updating the value of the action just taken based on the reward and future potential.</p>
</li>
</ul>
<h4 id="heading-4-minimax-for-expert-mode">4. Minimax for Expert Mode</h4>
<p>For our 'Expert' level, we'll implement the minimax algorithm, a classic recursive algorithm from game theory that guarantees perfect play.</p>
<pre><code class="lang-javascript">  getMinimaxAction(state, available) {
    <span class="hljs-keyword">let</span> bestScore = -<span class="hljs-literal">Infinity</span>;
    <span class="hljs-keyword">let</span> bestMove = available[<span class="hljs-number">0</span>];

    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> move <span class="hljs-keyword">of</span> available) {
      <span class="hljs-keyword">const</span> newState = state.substring(<span class="hljs-number">0</span>, move) + <span class="hljs-string">'O'</span> + state.substring(move + <span class="hljs-number">1</span>);
      <span class="hljs-keyword">const</span> score = <span class="hljs-built_in">this</span>.minimax(newState, <span class="hljs-number">0</span>, <span class="hljs-literal">false</span>);
      <span class="hljs-keyword">if</span> (score &gt; bestScore) {
        bestScore = score;
        bestMove = move;
      }
    }
    <span class="hljs-keyword">return</span> bestMove;
  }

  minimax(state, depth, isMaximizing) {
    <span class="hljs-keyword">const</span> winner = <span class="hljs-built_in">this</span>.checkWinnerStatic(state);
    <span class="hljs-keyword">if</span> (winner === <span class="hljs-string">'O'</span>) <span class="hljs-keyword">return</span> <span class="hljs-number">10</span> - depth;
    <span class="hljs-keyword">if</span> (winner === <span class="hljs-string">'X'</span>) <span class="hljs-keyword">return</span> depth - <span class="hljs-number">10</span>;
    <span class="hljs-keyword">if</span> (winner === <span class="hljs-string">'draw'</span>) <span class="hljs-keyword">return</span> <span class="hljs-number">0</span>;

    <span class="hljs-keyword">const</span> available = [...state].map(<span class="hljs-function">(<span class="hljs-params">c, i</span>) =&gt;</span> c === <span class="hljs-string">'-'</span> ? i : <span class="hljs-literal">null</span>).filter(<span class="hljs-function"><span class="hljs-params">x</span> =&gt;</span> x !== <span class="hljs-literal">null</span>);

    <span class="hljs-keyword">if</span> (isMaximizing) {
      <span class="hljs-keyword">let</span> best = -<span class="hljs-literal">Infinity</span>;
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> move <span class="hljs-keyword">of</span> available) {
        <span class="hljs-keyword">const</span> newState = state.substring(<span class="hljs-number">0</span>, move) + <span class="hljs-string">'O'</span> + state.substring(move + <span class="hljs-number">1</span>);
        best = <span class="hljs-built_in">Math</span>.max(best, <span class="hljs-built_in">this</span>.minimax(newState, depth + <span class="hljs-number">1</span>, <span class="hljs-literal">false</span>));
      }
      <span class="hljs-keyword">return</span> best;
    } <span class="hljs-keyword">else</span> {
      <span class="hljs-keyword">let</span> best = <span class="hljs-literal">Infinity</span>;
      <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> move <span class="hljs-keyword">of</span> available) {
        <span class="hljs-keyword">const</span> newState = state.substring(<span class="hljs-number">0</span>, move) + <span class="hljs-string">'X'</span> + state.substring(move + <span class="hljs-number">1</span>);
        best = <span class="hljs-built_in">Math</span>.min(best, <span class="hljs-built_in">this</span>.minimax(newState, depth + <span class="hljs-number">1</span>, <span class="hljs-literal">true</span>));
      }
      <span class="hljs-keyword">return</span> best;
    }
  }

  checkWinnerStatic(state) {
    <span class="hljs-keyword">const</span> patterns = [[<span class="hljs-number">0</span>,<span class="hljs-number">1</span>,<span class="hljs-number">2</span>],[<span class="hljs-number">3</span>,<span class="hljs-number">4</span>,<span class="hljs-number">5</span>],[<span class="hljs-number">6</span>,<span class="hljs-number">7</span>,<span class="hljs-number">8</span>],[<span class="hljs-number">0</span>,<span class="hljs-number">3</span>,<span class="hljs-number">6</span>],[<span class="hljs-number">1</span>,<span class="hljs-number">4</span>,<span class="hljs-number">7</span>],[<span class="hljs-number">2</span>,<span class="hljs-number">5</span>,<span class="hljs-number">8</span>],[<span class="hljs-number">0</span>,<span class="hljs-number">4</span>,<span class="hljs-number">8</span>],[<span class="hljs-number">2</span>,<span class="hljs-number">4</span>,<span class="hljs-number">6</span>]];
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> p <span class="hljs-keyword">of</span> patterns) {
      <span class="hljs-keyword">if</span> (state[p[<span class="hljs-number">0</span>]] !== <span class="hljs-string">'-'</span> &amp;&amp; state[p[<span class="hljs-number">0</span>]] === state[p[<span class="hljs-number">1</span>]] &amp;&amp; state[p[<span class="hljs-number">1</span>]] === state[p[<span class="hljs-number">2</span>]]) {
        <span class="hljs-keyword">return</span> state[p[<span class="hljs-number">0</span>]];
      }
    }
    <span class="hljs-keyword">return</span> state.includes(<span class="hljs-string">'-'</span>) ? <span class="hljs-literal">null</span> : <span class="hljs-string">'draw'</span>;
  }
</code></pre>
<h4 id="heading-5-helper-and-persistence-methods">5. Helper and Persistence Methods</h4>
<p>Finally, let's add methods for epsilon decay, resetting the AI's memory, and saving/loading the Q-table to <code>localStorage</code>.</p>
<pre><code class="lang-javascript">  decay() {
    <span class="hljs-built_in">this</span>.epsilon = <span class="hljs-built_in">Math</span>.max(<span class="hljs-number">0.01</span>, <span class="hljs-built_in">this</span>.epsilon * <span class="hljs-number">0.995</span>);
  }

  reset() {
    <span class="hljs-built_in">this</span>.q.clear();
    <span class="hljs-built_in">this</span>.epsilon = <span class="hljs-number">0.1</span>;
  }

  save() {
    <span class="hljs-keyword">const</span> data = {
      <span class="hljs-attr">q</span>: <span class="hljs-built_in">Array</span>.from(<span class="hljs-built_in">this</span>.q.entries()),
      <span class="hljs-attr">lr</span>: <span class="hljs-built_in">this</span>.lr,
      <span class="hljs-attr">gamma</span>: <span class="hljs-built_in">this</span>.gamma,
      <span class="hljs-attr">epsilon</span>: <span class="hljs-built_in">this</span>.epsilon,
      <span class="hljs-attr">difficulty</span>: <span class="hljs-built_in">this</span>.difficulty
    };
    <span class="hljs-built_in">localStorage</span>.setItem(<span class="hljs-string">'tictactoe_ai'</span>, <span class="hljs-built_in">JSON</span>.stringify(data));
  }

  load() {
    <span class="hljs-keyword">const</span> saved = <span class="hljs-built_in">localStorage</span>.getItem(<span class="hljs-string">'tictactoe_ai'</span>);
    <span class="hljs-keyword">if</span> (!saved) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;

    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> data = <span class="hljs-built_in">JSON</span>.parse(saved);
      <span class="hljs-built_in">this</span>.q = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>(data.q);
      <span class="hljs-built_in">this</span>.lr = data.lr;
      <span class="hljs-built_in">this</span>.gamma = data.gamma;
      <span class="hljs-built_in">this</span>.epsilon = data.epsilon;
      <span class="hljs-built_in">this</span>.difficulty = data.difficulty || <span class="hljs-string">'intermediate'</span>;
      <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
    } <span class="hljs-keyword">catch</span> (e) {
      <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Failed to load AI state:'</span>, e);
      <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
    }
  }

  clearStorage() {
    <span class="hljs-built_in">localStorage</span>.removeItem(<span class="hljs-string">'tictactoe_ai'</span>);
  }
}
</code></pre>
<h3 id="heading-the-tictactoe-class-managing-the-game">The <code>TicTacToe</code> Class: Managing the Game</h3>
<p>Now that we have our AI "brain," we need to build the game around it. This class will handle rendering the board, processing user clicks, managing game flow, and calling the AI when it's its turn.</p>
<h4 id="heading-1-constructor-and-control-initialization">1. Constructor and Control Initialization</h4>
<p>The constructor sets up the game's initial state, gets a reference to the HTML canvas, and wires up event listeners for user input.</p>
<pre><code class="lang-javascript"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">TicTacToe</span> </span>{
  <span class="hljs-keyword">constructor</span>() {
    <span class="hljs-built_in">this</span>.board = <span class="hljs-string">'---------'</span>;
    <span class="hljs-built_in">this</span>.ai = <span class="hljs-keyword">new</span> QLearning();
    <span class="hljs-built_in">this</span>.stats = { <span class="hljs-attr">played</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">aiWins</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">playerWins</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">draws</span>: <span class="hljs-number">0</span> };
    <span class="hljs-built_in">this</span>.training = <span class="hljs-literal">false</span>;
    <span class="hljs-built_in">this</span>.gameOver = <span class="hljs-literal">false</span>;

    <span class="hljs-built_in">this</span>.canvas = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'gameCanvas'</span>);
    <span class="hljs-built_in">this</span>.ctx = <span class="hljs-built_in">this</span>.canvas.getContext(<span class="hljs-string">'2d'</span>);
    <span class="hljs-built_in">this</span>.cellSize = <span class="hljs-number">133.33</span>;

    <span class="hljs-built_in">this</span>.canvas.onclick = <span class="hljs-function"><span class="hljs-params">e</span> =&gt;</span> <span class="hljs-built_in">this</span>.handleClick(e);
    <span class="hljs-built_in">this</span>.initControls();
    <span class="hljs-built_in">this</span>.loadState();
    <span class="hljs-built_in">this</span>.draw();
  }

  initControls() {
    [<span class="hljs-string">'learningRate'</span>, <span class="hljs-string">'discountFactor'</span>, <span class="hljs-string">'explorationRate'</span>].forEach(<span class="hljs-function"><span class="hljs-params">id</span> =&gt;</span> {
      <span class="hljs-keyword">const</span> el = <span class="hljs-built_in">document</span>.getElementById(id);
      el.oninput = <span class="hljs-function"><span class="hljs-params">e</span> =&gt;</span> {
        <span class="hljs-keyword">const</span> val = <span class="hljs-built_in">parseFloat</span>(e.target.value);
        <span class="hljs-built_in">document</span>.getElementById(id + <span class="hljs-string">'Value'</span>).textContent = val.toFixed(<span class="hljs-number">2</span>);
        <span class="hljs-keyword">if</span> (id === <span class="hljs-string">'learningRate'</span>) <span class="hljs-built_in">this</span>.ai.lr = val;
        <span class="hljs-keyword">if</span> (id === <span class="hljs-string">'discountFactor'</span>) <span class="hljs-built_in">this</span>.ai.gamma = val;
        <span class="hljs-keyword">if</span> (id === <span class="hljs-string">'explorationRate'</span>) <span class="hljs-built_in">this</span>.ai.epsilon = val;
        <span class="hljs-built_in">this</span>.saveState();
      };
    });
  }
</code></pre>
<p><code>initControls</code> connects our HTML sliders to the AI's parameters, allowing for real-time adjustments.</p>
<h4 id="heading-2-difficulty-and-ui-methods">2. Difficulty and UI Methods</h4>
<p>These methods manage the difficulty setting and update the UI accordingly.</p>
<pre><code class="lang-javascript">  setDifficulty(level) {
    <span class="hljs-built_in">this</span>.ai.difficulty = level;

    <span class="hljs-comment">// Update button styles</span>
    [<span class="hljs-string">'beginner'</span>, <span class="hljs-string">'intermediate'</span>, <span class="hljs-string">'expert'</span>].forEach(<span class="hljs-function"><span class="hljs-params">diff</span> =&gt;</span> {
      <span class="hljs-keyword">const</span> btn = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">`diff<span class="hljs-subst">${diff.charAt(<span class="hljs-number">0</span>).toUpperCase() + diff.slice(<span class="hljs-number">1</span>)}</span>`</span>);
      <span class="hljs-keyword">if</span> (diff === level) {
        btn.className = <span class="hljs-string">'py-2 px-4 rounded-lg font-semibold text-sm transition-all bg-purple-600 text-white border-2 border-purple-600'</span>;
      } <span class="hljs-keyword">else</span> {
        btn.className = <span class="hljs-string">'py-2 px-4 rounded-lg font-semibold text-sm transition-all bg-white text-gray-700 hover:bg-gray-100'</span>;
      }
    });

    <span class="hljs-keyword">if</span> (level === <span class="hljs-string">'beginner'</span>) <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'🌱 Beginner mode: AI makes more mistakes'</span>);
    <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (level === <span class="hljs-string">'intermediate'</span>) <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'🎯 Medium mode: Balanced AI using Q-learning'</span>);
    <span class="hljs-keyword">else</span> <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'🔥 Expert mode: Perfect AI using minimax algorithm'</span>);

    <span class="hljs-built_in">this</span>.saveState();
  }
</code></pre>
<h4 id="heading-3-drawing-and-rendering">3. Drawing and Rendering</h4>
<p>These methods use the HTML5 Canvas API to visually represent the game state.</p>
<pre><code class="lang-javascript">  draw() {
    <span class="hljs-keyword">const</span> { ctx, canvas, cellSize } = <span class="hljs-built_in">this</span>;
    ctx.fillStyle = <span class="hljs-string">'#fff'</span>;
    ctx.fillRect(<span class="hljs-number">0</span>, <span class="hljs-number">0</span>, canvas.width, canvas.height);

    ctx.strokeStyle = <span class="hljs-string">'#8b5cf6'</span>;
    ctx.lineWidth = <span class="hljs-number">4</span>;
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">1</span>; i &lt; <span class="hljs-number">3</span>; i++) {
      ctx.beginPath();
      ctx.moveTo(i * cellSize, <span class="hljs-number">0</span>);
      ctx.lineTo(i * cellSize, canvas.height);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(<span class="hljs-number">0</span>, i * cellSize);
      ctx.lineTo(canvas.width, i * cellSize);
      ctx.stroke();
    }

    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">9</span>; i++) {
      <span class="hljs-keyword">const</span> symbol = <span class="hljs-built_in">this</span>.board[i];
      <span class="hljs-keyword">if</span> (symbol === <span class="hljs-string">'-'</span>) <span class="hljs-keyword">continue</span>;

      <span class="hljs-keyword">const</span> x = (i % <span class="hljs-number">3</span>) * cellSize + cellSize / <span class="hljs-number">2</span>;
      <span class="hljs-keyword">const</span> y = ~~(i / <span class="hljs-number">3</span>) * cellSize + cellSize / <span class="hljs-number">2</span>;

      ctx.strokeStyle = symbol === <span class="hljs-string">'X'</span> ? <span class="hljs-string">'#ef4444'</span> : <span class="hljs-string">'#10b981'</span>;
      ctx.lineWidth = <span class="hljs-number">8</span>;
      ctx.lineCap = <span class="hljs-string">'round'</span>;

      <span class="hljs-keyword">if</span> (symbol === <span class="hljs-string">'X'</span>) {
        <span class="hljs-keyword">const</span> s = cellSize * <span class="hljs-number">0.3</span>;
        ctx.beginPath();
        ctx.moveTo(x - s, y - s);
        ctx.lineTo(x + s, y + s);
        ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(x + s, y - s);
        ctx.lineTo(x - s, y + s);
        ctx.stroke();
      } <span class="hljs-keyword">else</span> {
        ctx.beginPath();
        ctx.arc(x, y, cellSize * <span class="hljs-number">0.3</span>, <span class="hljs-number">0</span>, <span class="hljs-built_in">Math</span>.PI * <span class="hljs-number">2</span>);
        ctx.stroke();
      }
    }

    <span class="hljs-keyword">const</span> winner = <span class="hljs-built_in">this</span>.checkWinner();
    <span class="hljs-keyword">if</span> (winner?.line) <span class="hljs-built_in">this</span>.drawWinLine(winner.line);
  }

  drawWinLine(line) {
    <span class="hljs-keyword">const</span> [a, , c] = line;
    <span class="hljs-keyword">const</span> startX = (a % <span class="hljs-number">3</span>) * <span class="hljs-built_in">this</span>.cellSize + <span class="hljs-built_in">this</span>.cellSize / <span class="hljs-number">2</span>;
    <span class="hljs-keyword">const</span> startY = ~~(a / <span class="hljs-number">3</span>) * <span class="hljs-built_in">this</span>.cellSize + <span class="hljs-built_in">this</span>.cellSize / <span class="hljs-number">2</span>;
    <span class="hljs-keyword">const</span> endX = (c % <span class="hljs-number">3</span>) * <span class="hljs-built_in">this</span>.cellSize + <span class="hljs-built_in">this</span>.cellSize / <span class="hljs-number">2</span>;
    <span class="hljs-keyword">const</span> endY = ~~(c / <span class="hljs-number">3</span>) * <span class="hljs-built_in">this</span>.cellSize + <span class="hljs-built_in">this</span>.cellSize / <span class="hljs-number">2</span>;

    <span class="hljs-built_in">this</span>.ctx.strokeStyle = <span class="hljs-string">'#fbbf24'</span>;
    <span class="hljs-built_in">this</span>.ctx.lineWidth = <span class="hljs-number">6</span>;
    <span class="hljs-built_in">this</span>.ctx.beginPath();
    <span class="hljs-built_in">this</span>.ctx.moveTo(startX, startY);
    <span class="hljs-built_in">this</span>.ctx.lineTo(endX, endY);
    <span class="hljs-built_in">this</span>.ctx.stroke();
  }
</code></pre>
<h4 id="heading-4-player-interaction-and-the-game-loop">4. Player Interaction and the Game Loop</h4>
<p>This is the core interactive logic. <code>handleClick</code> translates a click into a board position, <code>move</code> updates the state, and <code>aiMove</code> gets an action from the <code>QLearning</code> class and executes it.</p>
<pre><code class="lang-javascript">  handleClick(e) {
    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">this</span>.gameOver || <span class="hljs-built_in">this</span>.training) <span class="hljs-keyword">return</span>;

    <span class="hljs-keyword">const</span> rect = <span class="hljs-built_in">this</span>.canvas.getBoundingClientRect();
    <span class="hljs-keyword">const</span> col = ~~((e.clientX - rect.left) / <span class="hljs-built_in">this</span>.cellSize);
    <span class="hljs-keyword">const</span> row = ~~((e.clientY - rect.top) / <span class="hljs-built_in">this</span>.cellSize);
    <span class="hljs-keyword">const</span> idx = row * <span class="hljs-number">3</span> + col;

    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">this</span>.board[idx] === <span class="hljs-string">'-'</span>) {
      <span class="hljs-built_in">this</span>.move(idx, <span class="hljs-string">'X'</span>);
      <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">this</span>.gameOver) <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">this</span>.aiMove(), <span class="hljs-number">300</span>);
    }
  }

  move(idx, player) {
    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">this</span>.board[idx] !== <span class="hljs-string">'-'</span> || <span class="hljs-built_in">this</span>.gameOver) <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
    <span class="hljs-built_in">this</span>.board = <span class="hljs-built_in">this</span>.board.substring(<span class="hljs-number">0</span>, idx) + player + <span class="hljs-built_in">this</span>.board.substring(idx + <span class="hljs-number">1</span>);
    <span class="hljs-built_in">this</span>.draw();
    <span class="hljs-built_in">this</span>.checkGameOver();
    <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
  }

  aiMove() {
    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">this</span>.gameOver) <span class="hljs-keyword">return</span>;

    <span class="hljs-keyword">const</span> state = <span class="hljs-built_in">this</span>.board;
    <span class="hljs-keyword">const</span> available = <span class="hljs-built_in">this</span>.getAvailable();
    <span class="hljs-keyword">const</span> action = <span class="hljs-built_in">this</span>.ai.getAction(state, available);

    <span class="hljs-built_in">this</span>.move(action, <span class="hljs-string">'O'</span>);

    <span class="hljs-keyword">const</span> winner = <span class="hljs-built_in">this</span>.checkWinner();
    <span class="hljs-keyword">const</span> reward = winner?.winner === <span class="hljs-string">'O'</span> ? <span class="hljs-number">1</span> : winner?.winner === <span class="hljs-string">'X'</span> ? <span class="hljs-number">-1</span> : <span class="hljs-number">0</span>;
    <span class="hljs-built_in">this</span>.ai.update(state, action, reward, <span class="hljs-built_in">this</span>.board, <span class="hljs-built_in">this</span>.getAvailable());
  }
</code></pre>
<p>After the AI moves, it immediately calls <code>this.ai.update()</code> to learn from the result of its action.</p>
<h4 id="heading-5-the-rules-engine">5. The Rules Engine</h4>
<p>These helpers determine the game's state: available moves, winner, and game over conditions.</p>
<pre><code class="lang-javascript">  getAvailable() {
    <span class="hljs-keyword">return</span> [...this.board].map(<span class="hljs-function">(<span class="hljs-params">c, i</span>) =&gt;</span> c === <span class="hljs-string">'-'</span> ? i : <span class="hljs-literal">null</span>).filter(<span class="hljs-function"><span class="hljs-params">x</span> =&gt;</span> x !== <span class="hljs-literal">null</span>);
  }

  checkWinner() {
    <span class="hljs-keyword">const</span> patterns = [[<span class="hljs-number">0</span>,<span class="hljs-number">1</span>,<span class="hljs-number">2</span>],[<span class="hljs-number">3</span>,<span class="hljs-number">4</span>,<span class="hljs-number">5</span>],[<span class="hljs-number">6</span>,<span class="hljs-number">7</span>,<span class="hljs-number">8</span>],[<span class="hljs-number">0</span>,<span class="hljs-number">3</span>,<span class="hljs-number">6</span>],[<span class="hljs-number">1</span>,<span class="hljs-number">4</span>,<span class="hljs-number">7</span>],[<span class="hljs-number">2</span>,<span class="hljs-number">5</span>,<span class="hljs-number">8</span>],[<span class="hljs-number">0</span>,<span class="hljs-number">4</span>,<span class="hljs-number">8</span>],[<span class="hljs-number">2</span>,<span class="hljs-number">4</span>,<span class="hljs-number">6</span>]];
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> p <span class="hljs-keyword">of</span> patterns) {
      <span class="hljs-keyword">if</span> (<span class="hljs-built_in">this</span>.board[p[<span class="hljs-number">0</span>]] !== <span class="hljs-string">'-'</span> &amp;&amp; 
          <span class="hljs-built_in">this</span>.board[p[<span class="hljs-number">0</span>]] === <span class="hljs-built_in">this</span>.board[p[<span class="hljs-number">1</span>]] &amp;&amp; 
          <span class="hljs-built_in">this</span>.board[p[<span class="hljs-number">1</span>]] === <span class="hljs-built_in">this</span>.board[p[<span class="hljs-number">2</span>]]) {
        <span class="hljs-keyword">return</span> { <span class="hljs-attr">winner</span>: <span class="hljs-built_in">this</span>.board[p[<span class="hljs-number">0</span>]], <span class="hljs-attr">line</span>: p };
      }
    }
    <span class="hljs-keyword">return</span> <span class="hljs-built_in">this</span>.board.includes(<span class="hljs-string">'-'</span>) ? <span class="hljs-literal">null</span> : { <span class="hljs-attr">winner</span>: <span class="hljs-string">'draw'</span>, <span class="hljs-attr">line</span>: <span class="hljs-literal">null</span> };
  }

  checkGameOver() {
    <span class="hljs-keyword">const</span> result = <span class="hljs-built_in">this</span>.checkWinner();
    <span class="hljs-keyword">if</span> (!result) <span class="hljs-keyword">return</span>;

    <span class="hljs-built_in">this</span>.gameOver = <span class="hljs-literal">true</span>;
    <span class="hljs-built_in">this</span>.stats.played++;

    <span class="hljs-keyword">if</span> (result.winner === <span class="hljs-string">'X'</span>) {
      <span class="hljs-built_in">this</span>.stats.playerWins++;
      <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">this</span>.training) <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'🎉 You win!'</span>);
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (result.winner === <span class="hljs-string">'O'</span>) {
      <span class="hljs-built_in">this</span>.stats.aiWins++;
      <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">this</span>.training) <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'🤖 AI wins!'</span>);
    } <span class="hljs-keyword">else</span> {
      <span class="hljs-built_in">this</span>.stats.draws++;
      <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">this</span>.training) <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'🤝 Draw!'</span>);
    }

    <span class="hljs-keyword">if</span> (!<span class="hljs-built_in">this</span>.training) {
      <span class="hljs-built_in">this</span>.updateStats();
      <span class="hljs-built_in">this</span>.saveState();
    }
  }
</code></pre>
<h4 id="heading-6-ui-and-statistics-updates">6. UI and Statistics Updates</h4>
<p>These methods connect the internal game state to the HTML elements, displaying status messages and statistics.</p>
<pre><code class="lang-javascript">  setStatus(msg) {
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'gameStatus'</span>).textContent = msg;
  }

  updateStats() {
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'gamesPlayed'</span>).textContent = <span class="hljs-built_in">this</span>.stats.played;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'aiWins'</span>).textContent = <span class="hljs-built_in">this</span>.stats.aiWins;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'playerWins'</span>).textContent = <span class="hljs-built_in">this</span>.stats.playerWins;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'draws'</span>).textContent = <span class="hljs-built_in">this</span>.stats.draws;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'statesLearned'</span>).textContent = <span class="hljs-built_in">this</span>.ai.q.size;

    <span class="hljs-keyword">const</span> winRate = <span class="hljs-built_in">this</span>.stats.played ? (<span class="hljs-built_in">this</span>.stats.aiWins / <span class="hljs-built_in">this</span>.stats.played * <span class="hljs-number">100</span>).toFixed(<span class="hljs-number">1</span>) : <span class="hljs-number">0</span>;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'winRate'</span>).textContent = <span class="hljs-string">`<span class="hljs-subst">${winRate}</span>%`</span>;
  }
</code></pre>
<h4 id="heading-7-game-and-ai-management">7. Game and AI Management</h4>
<p>These methods are wired to the control buttons for resetting the game or the AI's memory.</p>
<pre><code class="lang-javascript">  reset() {
    <span class="hljs-built_in">this</span>.board = <span class="hljs-string">'---------'</span>;
    <span class="hljs-built_in">this</span>.gameOver = <span class="hljs-literal">false</span>;
    <span class="hljs-built_in">this</span>.draw();
    <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'Your turn! (X)'</span>);
  }

  resetAI() {
    <span class="hljs-keyword">if</span> (confirm(<span class="hljs-string">'Reset AI memory? All progress will be lost.'</span>)) {
      <span class="hljs-built_in">this</span>.ai.reset();
      <span class="hljs-built_in">this</span>.ai.clearStorage();
      <span class="hljs-built_in">this</span>.stats = { <span class="hljs-attr">played</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">aiWins</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">playerWins</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">draws</span>: <span class="hljs-number">0</span> };
      <span class="hljs-built_in">this</span>.updateStats();
      <span class="hljs-built_in">this</span>.reset();
      <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'AI memory reset!'</span>);
      <span class="hljs-built_in">localStorage</span>.removeItem(<span class="hljs-string">'tictactoe_stats'</span>);
    }
  }
</code></pre>
<h4 id="heading-8-the-self-play-training-loop">8. The Self-Play Training Loop</h4>
<p>This is the logic for the "Train AI" button, allowing the AI to learn rapidly by playing against itself.</p>
<pre><code class="lang-javascript">  <span class="hljs-keyword">async</span> startTraining() {
    <span class="hljs-built_in">this</span>.training = <span class="hljs-literal">true</span>;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'trainingIndicator'</span>).classList.remove(<span class="hljs-string">'hidden'</span>);

    <span class="hljs-keyword">const</span> originalEpsilon = <span class="hljs-built_in">this</span>.ai.epsilon;
    <span class="hljs-built_in">this</span>.ai.epsilon = <span class="hljs-number">0.3</span>; <span class="hljs-comment">// Higher exploration during training</span>

    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">1000</span>; i++) {
      <span class="hljs-keyword">await</span> <span class="hljs-built_in">this</span>.trainGame();
      <span class="hljs-built_in">this</span>.ai.decay();
      <span class="hljs-keyword">if</span> (i % <span class="hljs-number">50</span> === <span class="hljs-number">0</span>) {
        <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'trainingProgress'</span>).textContent = <span class="hljs-string">`<span class="hljs-subst">${i + <span class="hljs-number">1</span>}</span>/1000`</span>;
        <span class="hljs-keyword">await</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Promise</span>(<span class="hljs-function"><span class="hljs-params">r</span> =&gt;</span> <span class="hljs-built_in">setTimeout</span>(r, <span class="hljs-number">0</span>)); <span class="hljs-comment">// Allow UI to update</span>
      }
    }

    <span class="hljs-built_in">this</span>.ai.epsilon = originalEpsilon;
    <span class="hljs-built_in">this</span>.training = <span class="hljs-literal">false</span>;
    <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'trainingIndicator'</span>).classList.add(<span class="hljs-string">'hidden'</span>);
    <span class="hljs-built_in">this</span>.updateStats();
    <span class="hljs-built_in">this</span>.reset();
    <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'Training complete!'</span>);
    <span class="hljs-built_in">this</span>.saveState();
  }

  <span class="hljs-keyword">async</span> trainGame() {
    <span class="hljs-built_in">this</span>.board = <span class="hljs-string">'---------'</span>;
    <span class="hljs-built_in">this</span>.gameOver = <span class="hljs-literal">false</span>;
    <span class="hljs-keyword">const</span> moves = [];

    <span class="hljs-keyword">while</span> (!<span class="hljs-built_in">this</span>.gameOver &amp;&amp; <span class="hljs-built_in">this</span>.getAvailable().length &gt; <span class="hljs-number">0</span>) {
      <span class="hljs-keyword">const</span> state = <span class="hljs-built_in">this</span>.board;
      <span class="hljs-keyword">const</span> available = <span class="hljs-built_in">this</span>.getAvailable();
      <span class="hljs-comment">// Alternate players (X and O) are both the AI</span>
      <span class="hljs-keyword">const</span> player = moves.length % <span class="hljs-number">2</span> === <span class="hljs-number">0</span> ? <span class="hljs-string">'X'</span> : <span class="hljs-string">'O'</span>; 
      <span class="hljs-keyword">const</span> action = <span class="hljs-built_in">this</span>.ai.getAction(state, available);

      moves.push({ state, action, player });
      <span class="hljs-built_in">this</span>.move(action, player);
    }

    <span class="hljs-keyword">const</span> winner = <span class="hljs-built_in">this</span>.checkWinner();
    <span class="hljs-comment">// Assign rewards after the game is over</span>
    moves.forEach(<span class="hljs-function"><span class="hljs-params">m</span> =&gt;</span> {
      <span class="hljs-keyword">const</span> reward = winner?.winner === m.player ? <span class="hljs-number">1</span> : (winner?.winner &amp;&amp; winner.winner !== m.player) ? <span class="hljs-number">-1</span> : <span class="hljs-number">0</span>;
      <span class="hljs-built_in">this</span>.ai.update(m.state, m.action, reward, <span class="hljs-built_in">this</span>.board, []);
    });
  }
</code></pre>
<h4 id="heading-9-state-persistence">9. State Persistence</h4>
<p>These methods orchestrate saving and loading the game state and AI's memory to <code>localStorage</code>.</p>
<pre><code class="lang-javascript">  saveState() {
    <span class="hljs-built_in">this</span>.ai.save();
    <span class="hljs-built_in">localStorage</span>.setItem(<span class="hljs-string">'tictactoe_stats'</span>, <span class="hljs-built_in">JSON</span>.stringify(<span class="hljs-built_in">this</span>.stats));
  }

  loadState() {
    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">this</span>.ai.load()) {
      <span class="hljs-keyword">const</span> savedStats = <span class="hljs-built_in">localStorage</span>.getItem(<span class="hljs-string">'tictactoe_stats'</span>);
      <span class="hljs-keyword">if</span> (savedStats) {
        <span class="hljs-built_in">this</span>.stats = <span class="hljs-built_in">JSON</span>.parse(savedStats);
      }
      <span class="hljs-built_in">this</span>.updateStats();
      <span class="hljs-built_in">this</span>.setDifficulty(<span class="hljs-built_in">this</span>.ai.difficulty);

      <span class="hljs-comment">// Update sliders to reflect loaded AI state</span>
      <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'learningRate'</span>).value = <span class="hljs-built_in">this</span>.ai.lr;
      <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'learningRateValue'</span>).textContent = <span class="hljs-built_in">this</span>.ai.lr.toFixed(<span class="hljs-number">2</span>);
      <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'discountFactor'</span>).value = <span class="hljs-built_in">this</span>.ai.gamma;
      <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'discountFactorValue'</span>).textContent = <span class="hljs-built_in">this</span>.ai.gamma.toFixed(<span class="hljs-number">2</span>);
      <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'explorationRate'</span>).value = <span class="hljs-built_in">this</span>.ai.epsilon;
      <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'explorationRateValue'</span>).textContent = <span class="hljs-built_in">this</span>.ai.epsilon.toFixed(<span class="hljs-number">2</span>);

      <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'✓ Loaded AI state from localStorage'</span>);
    }
  }
}
</code></pre>
<h4 id="heading-10-initializing-the-game">10. Initializing the Game</h4>
<p>Finally, add this snippet at the end of <code>game.js</code> to create an instance of the game once the HTML document is loaded.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">let</span> game;
<span class="hljs-built_in">window</span>.addEventListener(<span class="hljs-string">'DOMContentLoaded'</span>, <span class="hljs-function">() =&gt;</span> {
  game = <span class="hljs-keyword">new</span> TicTacToe();
});
</code></pre>
<p>This completes our implementation! You now have a fully functional <code>game.js</code> file. If you encountered any issues or want to double-check your work, you can compare your code against the complete source file available on GitHub: <a target="_blank" href="https://github.com/mayur9210/tic-tac-toe-ai/blob/main/game.js">https://github.com/mayur9210/tic-tac-toe-ai/blob/main/game.js</a>.</p>
<h2 id="heading-how-to-understand-the-enhanced-features">How to Understand the Enhanced Features</h2>
<p>Beyond the core Q-learning logic, this implementation includes several enhanced features to create a complete, user-friendly, and educational application. Let's explore what they are and how they work.</p>
<h3 id="heading-1-adaptive-difficulty-levels">1. Adaptive Difficulty Levels</h3>
<p>The game supports three distinct difficulty modes to cater to different players:</p>
<ul>
<li><p><strong>Beginner (🌱):</strong> This mode is designed for new players. The AI makes random moves 70% of the time, providing a high chance for the player to win and learn the game's rules.</p>
</li>
<li><p><strong>Intermediate (🎯):</strong> This is the standard mode where the AI uses the Q-learning algorithm with an epsilon-greedy strategy. It presents a challenging but fair opponent that improves over time.</p>
</li>
<li><p><strong>Expert (🔥):</strong> This mode switches from reinforcement learning to the classic <strong>minimax algorithm</strong>. This algorithm plays a perfect game, meaning it is impossible to beat (the best a player can achieve is a draw). This serves as a benchmark for optimal play.</p>
</li>
</ul>
<h3 id="heading-2-other-enhanced-features">2. Other Enhanced Features</h3>
<p>In addition to the difficulty levels, the application includes:</p>
<ul>
<li><p><strong>Real-time AI parameter tuning:</strong> The sliders in the UI allow you to adjust the Learning Rate (α), Discount Factor (γ), and Exploration Rate (ϵ) on the fly. This lets you directly observe how different hyperparameters affect the AI's learning speed and performance.</p>
</li>
<li><p><strong>Persistence with localStorage:</strong> The AI automatically saves its Q-table and your game statistics to the browser's local storage. When you close the tab and come back later, the AI will remember everything it has learned.</p>
</li>
<li><p><strong>Dedicated self-play training mode:</strong> The "Train AI" button allows the AI to play 1,000 games against itself in a matter of seconds. This rapidly populates the Q-table and is far more efficient than learning from just human-played games.</p>
</li>
</ul>
<h2 id="heading-putting-it-all-together-a-guided-test-run">Putting It All Together: A Guided Test Run</h2>
<p>Once you have the HTML (<code>index.html</code>) and JavaScript (<code>game.js</code>) files in same directory, open the HTML file in a web browser to test all the features. When you open the HTML file, it should look like as shown in the below image.</p>
<p>I have also <a target="_blank" href="https://mayur9210.github.io/tic-tac-toe-ai/">hosted this file on GitHub Pages</a> if you want to see how it works.</p>
<p>Now that you have the application running, let's walk through how to test the features and witness the AI's learning process firsthand. This interactive testing is the most rewarding part, as you'll see the abstract concepts come to life.</p>
<h3 id="heading-step-1-challenge-the-untrained-ai">Step 1: Challenge the Untrained AI</h3>
<p>When you first load the game, the AI is a blank slate. Its Q-table is empty. Make sure the difficulty is set to <strong>🌱 Beginner</strong> and play a game against it. You'll likely find it very easy to beat. It makes random, nonsensical moves because it has no experience. Notice the "States Learned" in the statistics panel is very low.</p>
<h3 id="heading-step-2-train-the-ai">Step 2: Train the AI</h3>
<p>Now for the magic. Click the <strong>"Train AI (1000 games)"</strong> button. You'll see the yellow training indicator appear with a progress counter. In these few seconds, the AI is playing 1,000 games against itself, rapidly learning from its wins, losses, and draws. For every move in every game, it updates its Q-table, reinforcing good strategies and penalizing bad ones.</p>
<h3 id="heading-step-3-challenge-the-trained-ai">Step 3: Challenge the Trained AI</h3>
<p>Once training is complete, play another game on <strong>🎯 Medium</strong> difficulty. The difference should be dramatic. The AI will now play strategically, blocking your wins and setting up its own. It is no longer a pushover. Check the statistics panel again: you'll see the "States Learned" count has jumped significantly, representing all the new board positions it now understands.</p>
<h3 id="heading-step-4-experiment-with-the-controls">Step 4: Experiment with the Controls</h3>
<p>Now that you have a trained AI, experiment with the other features:</p>
<ul>
<li><p><strong>Switch to 🔥 Expert:</strong> Play against the minimax algorithm. Notice that you can't win. This demonstrates the power of a perfect-play algorithm.</p>
</li>
<li><p><strong>Tweak the parameters:</strong> Set the Exploration Rate (ε) slider to 0. The AI will become completely deterministic, always picking the move with the highest Q-value. Set it to 0.5, and watch it become more erratic and experimental again.</p>
</li>
<li><p><strong>Reset the AI:</strong> Click the "Reset AI Memory" button. This will wipe its Q-table. If you play against it now, you'll find it's back to its original, untrained state. This confirms that its "intelligence" was stored in the Q-table you just erased.</p>
</li>
</ul>
<h3 id="heading-verifying-the-implementation-with-automated-tests">Verifying the Implementation with Automated Tests</h3>
<p>While playing the game gives you a good feel for the AI's behavior, automated tests are crucial for programmatically confirming that the underlying code is correct. This is different from the manual testing you just performed. Here, we are writing code to check our code.</p>
<p>The following test suite validates the three most critical features: difficulty switching, data persistence with <code>localStorage</code>, and the infallibility of the expert minimax AI. You can run these tests by copying and pasting the code into your browser's developer console while the game is open.</p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">runTests</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'🧪 Running enhanced tests...'</span>);

  <span class="hljs-comment">// Test 1: Difficulty switching</span>
  <span class="hljs-keyword">const</span> g1 = <span class="hljs-keyword">new</span> TicTacToe();
  g1.setDifficulty(<span class="hljs-string">'beginner'</span>);
  <span class="hljs-built_in">console</span>.assert(g1.ai.difficulty === <span class="hljs-string">'beginner'</span>, <span class="hljs-string">'✓ Difficulty switching works'</span>);

  <span class="hljs-comment">// Test 2: localStorage persistence</span>
  <span class="hljs-keyword">const</span> g2 = <span class="hljs-keyword">new</span> TicTacToe();
  g2.ai.q.set(<span class="hljs-string">'test-state'</span>, [<span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">3</span>, <span class="hljs-number">4</span>, <span class="hljs-number">5</span>, <span class="hljs-number">6</span>, <span class="hljs-number">7</span>, <span class="hljs-number">8</span>, <span class="hljs-number">9</span>]);
  g2.saveState();
  <span class="hljs-keyword">const</span> g3 = <span class="hljs-keyword">new</span> TicTacToe();
  <span class="hljs-built_in">console</span>.assert(g3.ai.q.has(<span class="hljs-string">'test-state'</span>), <span class="hljs-string">'✓ localStorage persistence works'</span>);

  <span class="hljs-comment">// Test 3: Minimax never loses</span>
  <span class="hljs-keyword">const</span> g4 = <span class="hljs-keyword">new</span> TicTacToe();
  g4.setDifficulty(<span class="hljs-string">'expert'</span>);
  <span class="hljs-keyword">let</span> expertLosses = <span class="hljs-number">0</span>;
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">100</span>; i++) {
    g4.reset();
    <span class="hljs-keyword">while</span> (!g4.gameOver) {
      <span class="hljs-keyword">const</span> available = g4.getAvailable();
      <span class="hljs-keyword">const</span> move = available[~~(<span class="hljs-built_in">Math</span>.random() * available.length)];
      g4.move(move, <span class="hljs-string">'X'</span>);
      <span class="hljs-keyword">if</span> (!g4.gameOver) g4.aiMove();
    }
    <span class="hljs-keyword">const</span> winner = g4.checkWinner();
    <span class="hljs-keyword">if</span> (winner?.winner === <span class="hljs-string">'X'</span>) expertLosses++;
  }
  <span class="hljs-built_in">console</span>.assert(expertLosses === <span class="hljs-number">0</span>, <span class="hljs-string">'✓ Expert AI never loses'</span>);

  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'✅ All tests passed!'</span>);
}
</code></pre>
<p>How these tests work:</p>
<ol>
<li><p><strong>Difficulty switching:</strong> The first test creates a game instance, sets the difficulty, and asserts that the AI's internal property was updated correctly.</p>
</li>
<li><p><strong>Persistence:</strong> The second test simulates saving the AI's state. It adds a dummy entry to the Q-table, saves it, creates a <em>new</em> game instance (simulating a page reload), and asserts that the new instance successfully loaded the saved data.</p>
</li>
<li><p><strong>Expert mode correctness:</strong> The third and most rigorous test plays 100 games against the expert AI using random moves for the player. It then asserts that the expert AI never lost a single game, proving the minimax implementation is correct.</p>
</li>
</ol>
<p>You can run these tests in your browser's console after loading the game as shown in the below screenshot.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1759790825366/aedc84b7-5399-4067-bf2c-b0b488192c62.png" alt="Running tests" class="image--center mx-auto" width="1454" height="924" loading="lazy"></p>
<h2 id="heading-advanced-optimizations-and-extensions">Advanced Optimizations and Extensions</h2>
<p>Now that you have the complete implementation, here are ways to extend it further:</p>
<h3 id="heading-how-to-implement-symmetry-reduction">How to Implement Symmetry Reduction</h3>
<p>You can reduce the state space by recognizing equivalent board positions:</p>
<pre><code class="lang-javascript">getCanonicalState(s) {
  <span class="hljs-keyword">const</span> transforms = [
    s, <span class="hljs-built_in">this</span>.rot90(s), <span class="hljs-built_in">this</span>.rot180(s), <span class="hljs-built_in">this</span>.rot270(s),
    <span class="hljs-built_in">this</span>.flip(s), <span class="hljs-built_in">this</span>.flip(<span class="hljs-built_in">this</span>.rot90(s)), 
    <span class="hljs-built_in">this</span>.flip(<span class="hljs-built_in">this</span>.rot180(s)), <span class="hljs-built_in">this</span>.flip(<span class="hljs-built_in">this</span>.rot270(s))
  ];
  <span class="hljs-keyword">return</span> transforms.sort()[<span class="hljs-number">0</span>];
}

rot90(s) {
  <span class="hljs-keyword">const</span> b = s.split(<span class="hljs-string">''</span>);
  <span class="hljs-keyword">return</span> [b[<span class="hljs-number">6</span>],b[<span class="hljs-number">3</span>],b[<span class="hljs-number">0</span>],b[<span class="hljs-number">7</span>],b[<span class="hljs-number">4</span>],b[<span class="hljs-number">1</span>],b[<span class="hljs-number">8</span>],b[<span class="hljs-number">5</span>],b[<span class="hljs-number">2</span>]].join(<span class="hljs-string">''</span>);
}

rot180(s) {
  <span class="hljs-keyword">return</span> s.split(<span class="hljs-string">''</span>).reverse().join(<span class="hljs-string">''</span>);
}

rot270(s) {
  <span class="hljs-keyword">const</span> b = s.split(<span class="hljs-string">''</span>);
  <span class="hljs-keyword">return</span> [b[<span class="hljs-number">2</span>],b[<span class="hljs-number">5</span>],b[<span class="hljs-number">8</span>],b[<span class="hljs-number">1</span>],b[<span class="hljs-number">4</span>],b[<span class="hljs-number">7</span>],b[<span class="hljs-number">0</span>],b[<span class="hljs-number">3</span>],b[<span class="hljs-number">6</span>]].join(<span class="hljs-string">''</span>);
}

flip(s) {
  <span class="hljs-keyword">const</span> b = s.split(<span class="hljs-string">''</span>);
  <span class="hljs-keyword">return</span> [b[<span class="hljs-number">2</span>],b[<span class="hljs-number">1</span>],b[<span class="hljs-number">0</span>],b[<span class="hljs-number">5</span>],b[<span class="hljs-number">4</span>],b[<span class="hljs-number">3</span>],b[<span class="hljs-number">8</span>],b[<span class="hljs-number">7</span>],b[<span class="hljs-number">6</span>]].join(<span class="hljs-string">''</span>);
}
</code></pre>
<p>This symmetry reduction technique speeds up AI learning by recognizing equivalent board positions.</p>
<p><strong>How it works:</strong></p>
<ul>
<li><p><strong>getCanonicalState()</strong>: Generates all 8 symmetric versions of a board state (4 rotations + 4 flipped versions) and returns the alphabetically first one as the standard representation</p>
</li>
<li><p><strong>rot90()</strong>: Rotates board 90° clockwise by remapping position indices</p>
</li>
<li><p><strong>rot180()</strong>: Rotates 180° by reversing the board array</p>
</li>
<li><p><strong>rot270()</strong>: Rotates 270° clockwise (or 90° counterclockwise)</p>
</li>
<li><p><strong>flip()</strong>: Mirrors the board horizontally</p>
</li>
</ul>
<p><strong>Why this matters:</strong> By storing only canonical states in the Q-table, the AI reduces unique positions from ~5,500 to ~700, making learning <strong>8x faster</strong>.</p>
<p><strong>Example:</strong> These boards are considered identical:</p>
<pre><code class="lang-bash">X-- --- --X
--- = --- = ---
--- --- ---
(original) (180° rotation) (horizontal flip)
</code></pre>
<p>All three map to the same canonical state, so the AI only needs to learn one instead of three.</p>
<p>Modify <code>getQ()</code> to use canonical states. This reduces learning time by 8x since the AI recognizes rotated and flipped positions as equivalent.</p>
<h3 id="heading-how-to-add-export-and-import-functionality">How to Add Export and Import Functionality</h3>
<p>You can also let users share trained AI models:</p>
<pre><code class="lang-javascript">exportAI() {
  <span class="hljs-keyword">const</span> data = {
    <span class="hljs-attr">q</span>: <span class="hljs-built_in">Array</span>.from(<span class="hljs-built_in">this</span>.ai.q.entries()),
    <span class="hljs-attr">stats</span>: <span class="hljs-built_in">this</span>.stats,
    <span class="hljs-attr">difficulty</span>: <span class="hljs-built_in">this</span>.ai.difficulty,
    <span class="hljs-attr">timestamp</span>: <span class="hljs-built_in">Date</span>.now()
  };

  <span class="hljs-keyword">const</span> blob = <span class="hljs-keyword">new</span> Blob([<span class="hljs-built_in">JSON</span>.stringify(data)], { <span class="hljs-attr">type</span>: <span class="hljs-string">'application/json'</span> });
  <span class="hljs-keyword">const</span> url = URL.createObjectURL(blob);
  <span class="hljs-keyword">const</span> a = <span class="hljs-built_in">document</span>.createElement(<span class="hljs-string">'a'</span>);
  a.href = url;
  a.download = <span class="hljs-string">`tictactoe-ai-<span class="hljs-subst">${<span class="hljs-built_in">Date</span>.now()}</span>.json`</span>;
  a.click();
  URL.revokeObjectURL(url);
}

importAI(file) {
  <span class="hljs-keyword">const</span> reader = <span class="hljs-keyword">new</span> FileReader();
  reader.onload = <span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> {
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> data = <span class="hljs-built_in">JSON</span>.parse(e.target.result);
      <span class="hljs-built_in">this</span>.ai.q = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>(data.q);
      <span class="hljs-built_in">this</span>.stats = data.stats;
      <span class="hljs-built_in">this</span>.ai.difficulty = data.difficulty;
      <span class="hljs-built_in">this</span>.updateStats();
      <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'✓ AI imported successfully!'</span>);
    } <span class="hljs-keyword">catch</span> (err) {
      <span class="hljs-built_in">this</span>.setStatus(<span class="hljs-string">'✗ Import failed: Invalid file'</span>);
    }
  };
  reader.readAsText(file);
}
</code></pre>
<p>These methods enable sharing trained AI models between users. The <code>exportAI()</code> method packages the complete AI state (Q-table, statistics, difficulty, and timestamp) into a JSON object, creates a Blob from the JSON string, generates a temporary download URL, programmatically creates and clicks a download link, then cleans up the URL. The filename includes a timestamp for version tracking.</p>
<p>The <code>importAI()</code> method uses FileReader to asynchronously read an uploaded JSON file, parses it, reconstructs the Map from the array of entries, restores all game state, and updates the display. Error handling catches invalid JSON or corrupted files.</p>
<h3 id="heading-how-to-add-q-value-heatmap-visualization">How to Add Q-Value Heatmap Visualization</h3>
<p>Here’s how you can visualize the AI's decision-making:</p>
<pre><code class="lang-javascript">drawQValueHeatmap() {
  <span class="hljs-keyword">const</span> state = <span class="hljs-built_in">this</span>.board;
  <span class="hljs-keyword">const</span> qValues = <span class="hljs-built_in">this</span>.ai.getQ(state);
  <span class="hljs-keyword">const</span> available = <span class="hljs-built_in">this</span>.getAvailable();

  <span class="hljs-keyword">if</span> (available.length === <span class="hljs-number">0</span>) <span class="hljs-keyword">return</span>;

  <span class="hljs-keyword">const</span> maxQ = <span class="hljs-built_in">Math</span>.max(...available.map(<span class="hljs-function"><span class="hljs-params">i</span> =&gt;</span> qValues[i]));
  <span class="hljs-keyword">const</span> minQ = <span class="hljs-built_in">Math</span>.min(...available.map(<span class="hljs-function"><span class="hljs-params">i</span> =&gt;</span> qValues[i]));
  <span class="hljs-keyword">const</span> range = maxQ - minQ || <span class="hljs-number">1</span>;

  <span class="hljs-built_in">this</span>.ctx.globalAlpha = <span class="hljs-number">0.3</span>;
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> i <span class="hljs-keyword">of</span> available) {
    <span class="hljs-keyword">const</span> normalized = (qValues[i] - minQ) / range;
    <span class="hljs-keyword">const</span> row = ~~(i / <span class="hljs-number">3</span>);
    <span class="hljs-keyword">const</span> col = i % <span class="hljs-number">3</span>;

    <span class="hljs-comment">// Green for high Q-values, red for low</span>
    <span class="hljs-keyword">const</span> hue = normalized * <span class="hljs-number">120</span>;
    <span class="hljs-built_in">this</span>.ctx.fillStyle = <span class="hljs-string">`hsl(<span class="hljs-subst">${hue}</span>, 70%, 50%)`</span>;
    <span class="hljs-built_in">this</span>.ctx.fillRect(
      col * <span class="hljs-built_in">this</span>.cellSize + <span class="hljs-number">5</span>,
      row * <span class="hljs-built_in">this</span>.cellSize + <span class="hljs-number">5</span>,
      <span class="hljs-built_in">this</span>.cellSize - <span class="hljs-number">10</span>,
      <span class="hljs-built_in">this</span>.cellSize - <span class="hljs-number">10</span>
    );

    <span class="hljs-comment">// Draw Q-value</span>
    <span class="hljs-built_in">this</span>.ctx.globalAlpha = <span class="hljs-number">1</span>;
    <span class="hljs-built_in">this</span>.ctx.fillStyle = <span class="hljs-string">'#000'</span>;
    <span class="hljs-built_in">this</span>.ctx.font = <span class="hljs-string">'14px monospace'</span>;
    <span class="hljs-built_in">this</span>.ctx.fillText(
      qValues[i].toFixed(<span class="hljs-number">2</span>),
      col * <span class="hljs-built_in">this</span>.cellSize + <span class="hljs-number">10</span>,
      row * <span class="hljs-built_in">this</span>.cellSize + <span class="hljs-number">25</span>
    );
  }
  <span class="hljs-built_in">this</span>.ctx.globalAlpha = <span class="hljs-number">1</span>;
}
</code></pre>
<p>This visualization method creates a color-coded heatmap showing the AI's confidence in each available move.</p>
<p>It first retrieves Q-values for the current state and finds the min/max values among available positions to normalize the data. For each empty cell, it calculates a normalized score (0 to 1), converts it to a hue value (0° red for low values, 120° green for high values) using HSL color space, and fills the cell with a semi-transparent colored rectangle. It then overlays the actual Q-value as text for precise inspection.</p>
<p>This gives you instant visual feedback about which moves the AI considers most promising. Green cells are good moves, red cells are poor moves.</p>
<h2 id="heading-common-pitfalls-and-solutions">Common Pitfalls and Solutions</h2>
<h3 id="heading-issue-1-ai-does-not-improve">Issue 1: AI Does Not Improve</h3>
<ul>
<li><p><strong>Cause</strong>: The learning rate is too low or there hasn't been enough training.</p>
</li>
<li><p><strong>Solution</strong>: Increase the learning rate to between 0.2 and 0.3, and train for more than 2000 games.</p>
</li>
</ul>
<h3 id="heading-issue-2-ai-makes-random-moves">Issue 2: AI Makes Random Moves</h3>
<ul>
<li><p><strong>Cause</strong>: The exploration rate is too high after training.</p>
</li>
<li><p><strong>Solution</strong>: Reduce the exploration rate to 0.01 once training is complete.</p>
</li>
</ul>
<h3 id="heading-issue-3-slow-performance">Issue 3: Slow Performance</h3>
<ul>
<li><p><strong>Cause</strong>: The state representation or Q-table lookup is inefficient.</p>
</li>
<li><p><strong>Solution</strong>: Use a Map instead of objects and implement state caching.</p>
</li>
</ul>
<h3 id="heading-issue-4-ai-overfits-to-one-strategy">Issue 4: AI Overfits to One Strategy</h3>
<ul>
<li><p><strong>Cause</strong>: There isn't enough exploration during training.</p>
</li>
<li><p><strong>Solution</strong>: Begin with a high exploration rate (ε=0.5) and gradually decrease it.</p>
</li>
</ul>
<h2 id="heading-how-to-extend-this-to-other-games">How to Extend This to Other Games</h2>
<p>This framework adapts to other games:</p>
<ul>
<li><p><strong>Connect Four</strong>: 42-character state, 7 actions (columns)</p>
</li>
<li><p><strong>Blackjack</strong>: State includes hand values and dealer card</p>
</li>
<li><p><strong>Snake</strong>: Continuous states require function approximation</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You have built a complete reinforcement learning system in JavaScript. This project demonstrates:</p>
<ul>
<li><p>Core RL concepts with practical implementation</p>
</li>
<li><p>Clean, maintainable code architecture</p>
</li>
<li><p>Real-time training and visualization</p>
</li>
<li><p>Advanced techniques like epsilon decay and self-play</p>
</li>
<li><p>Three difficulty levels from beginner to expert</p>
</li>
<li><p>Data persistence with localStorage</p>
</li>
<li><p>Interactive tooltips for learning</p>
</li>
</ul>
<p>The Q-learning foundation you have implemented powers more advanced techniques like Deep Q-Networks (DQN) used in modern game AI.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>Here are some ways to continue learning:</p>
<ol>
<li><p>Add more difficulty levels with custom parameters</p>
</li>
<li><p>Implement state persistence with IndexedDB for larger Q-tables</p>
</li>
<li><p>Create multiplayer mode with AI observation</p>
</li>
<li><p>Build a neural network version with TensorFlow.js</p>
</li>
<li><p>Extend to Connect Four or Chess endgames</p>
</li>
</ol>
<h3 id="heading-resources-for-further-learning">Resources for Further Learning</h3>
<ul>
<li><p><a target="_blank" href="http://incompleteideas.net/book/the-book.html">Reinforcement Learning: An Introduction</a> by Sutton and Barto (free online textbook)</p>
</li>
<li><p><a target="_blank" href="https://spinningup.openai.com/">OpenAI Spinning Up</a> – comprehensive RL resource</p>
</li>
<li><p><a target="_blank" href="https://sites.google.com/view/deep-rl-bootcamp/">Deep RL Bootcamp</a> – Berkeley video lectures</p>
</li>
<li><p><a target="_blank" href="https://stable-baselines3.readthedocs.io/">Stable-Baselines3 Documentation</a> – production RL implementations</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Production-Ready Web Apps with the Hono Framework: A Deep Dive ]]>
                </title>
                <description>
                    <![CDATA[ As a dev, you’d probably like to write your application once and not have to worry so much about where it's going to run. This is what the open source framework Hono lets you do, and it’s a game-changer. Hono is a small, incredibly fast web framework... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-production-ready-web-apps-with-hono/</link>
                <guid isPermaLink="false">68bf3ea02c935a9d306bb65a</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ hono ]]>
                    </category>
                
                    <category>
                        <![CDATA[ javascript framework ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mayur Vekariya ]]>
                </dc:creator>
                <pubDate>Mon, 08 Sep 2025 20:37:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1757363825321/562644c8-b2b3-4c1c-92c2-736bcade5aac.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As a dev, you’d probably like to write your application once and not have to worry so much about where it's going to run. This is what the open source framework Hono lets you do, and it’s a game-changer. Hono is a small, incredibly fast web framework that embraces the "write once, run anywhere" philosophy.</p>
<p>The JavaScript ecosystem moves quickly. One minute, we're building monolithic Node.js servers. The next, it's all about serverless functions and running code at the edge on platforms like Cloudflare or Vercel. Staying current can feel like a full-time job.</p>
<p><a target="_blank" href="https://hono.dev/">Hono</a> is built on top of Web Standards – the same <code>Request</code> and <code>Response</code> objects in your browser – which means your code is naturally portable across almost any JavaScript runtime.</p>
<p>This guide is a deep dive into this powerful little framework, designed to help you build real, production-ready applications. We’ll skip the quick "Hello, World!" and jump straight into the patterns and features you will actually use, with plenty of detailed code examples along the way.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-you-will-learn-in-this-guide">What You Will Learn in This Guide</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-prerequisites-for-following-along">Prerequisites for Following Along</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-a-professional-hono-project">How to Set Up a Professional Hono Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-understand-honos-core-api">How to Understand Hono's Core API</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-the-context-object-in-depth">The Context Object in Depth</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-use-advanced-features-for-production-apps">How to Use Advanced Features for Production Apps</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-deployment-guide-for-hono">Deployment Guide for Hono</a></p>
</li>
</ul>
<h3 id="heading-what-you-will-learn-in-this-guide">What You Will Learn in This Guide</h3>
<p>By the end of this tutorial, you will be able to:</p>
<ul>
<li><p>Structure a Hono project for both development and production.</p>
</li>
<li><p>Implement advanced routing patterns.</p>
</li>
<li><p>Leverage the full power of the <strong>Context</strong> object to manage requests and pass data between middleware.</p>
</li>
<li><p>Write complex custom middleware for authentication, logging, and error handling.</p>
</li>
<li><p>Validate incoming data using the official <strong>Zod</strong> validator for robust APIs.</p>
</li>
<li><p>Build a small, server-rendered application with <strong>JSX</strong> components.</p>
</li>
<li><p>Deploy a Hono application to various modern hosting platforms.</p>
</li>
</ul>
<h3 id="heading-prerequisites-for-following-along">Prerequisites for Following Along</h3>
<p>This is an in-depth guide, but it assumes you have some foundational knowledge. Before you start, you should have:</p>
<ul>
<li><p><strong>Node.js installed:</strong> Version 18 or higher is recommended.</p>
</li>
<li><p><strong>A code editor:</strong> Visual Studio Code is a great choice.</p>
</li>
<li><p><strong>Familiarity with TypeScript:</strong> You should understand basic types, functions, and <code>async</code>/<code>await</code>.</p>
</li>
<li><p><strong>Basic command-line knowledge:</strong> You should be comfortable running commands in your terminal.</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-a-professional-hono-project">How to Set Up a Professional Hono Project</h2>
<p>You can get started with Hono using a single command. This will create a new project directory with a recommended structure and configuration files. When prompted, select the <code>nodejs</code> template and choose to install dependencies with your preferred package manager (for example, npm).</p>
<pre><code class="lang-bash">npm create hono@latest hono-production-app
</code></pre>
<p>The command will guide you through the setup:</p>
<pre><code class="lang-bash">&gt; npx create-hono hono-production-app

create-hono version 0.19.2
✔ Using target directory … hono-production-app
✔ Which template <span class="hljs-keyword">do</span> you want to use? nodejs
✔ Do you want to install project dependencies? Yes
✔ Which package manager <span class="hljs-keyword">do</span> you want to use? npm
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: <span class="hljs-built_in">cd</span> hono-production-app
</code></pre>
<p>Now, navigate into your new directory: <code>cd hono-production-app</code>. Let's look at the files that were created:</p>
<ul>
<li><p><code>package.json</code>: Defines your project's dependencies and scripts.</p>
</li>
<li><p><code>tsconfig.json</code>: The TypeScript configuration file.</p>
</li>
<li><p><code>src/index.ts</code>: The entry point of your application.</p>
</li>
</ul>
<p>Now, you can run <code>npm run dev</code> to start your development server. Navigate to <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a>, and you will see "Hello Hono!".</p>
<h2 id="heading-how-to-understand-honos-core-api">How to Understand Hono's Core API</h2>
<p>Hono's API is designed to be minimal, which makes it easy to learn – yet incredibly powerful.</p>
<h3 id="heading-how-to-use-advanced-routing-techniques">How to Use Advanced Routing Techniques</h3>
<p>You may already know <code>app.get()</code> and <code>app.post()</code> from Express, but Hono's router can do much more.</p>
<h4 id="heading-1-how-to-route-with-regular-expressions">1. How to Route with Regular Expressions</h4>
<p>You can constrain a URL parameter to match a specific regular expression. For example, to make sure an <code>:id</code> parameter only accepts numbers, you can do this:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// Only match routes like /users/123, not /users/abc</span>
app.get(<span class="hljs-string">'/users/:id{[0-9]+}'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> id = c.req.param(<span class="hljs-string">'id'</span>)
  <span class="hljs-keyword">return</span> c.text(<span class="hljs-string">`Fetching data for user ID: <span class="hljs-subst">${id}</span>`</span>)
})
</code></pre>
<h4 id="heading-2-how-to-use-optional-and-wildcard-routes">2. How to Use Optional and Wildcard Routes</h4>
<p>You can define routes that match multiple paths using wildcards (<code>*</code>) or handle optional parameters.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// This will match /files/image.png, /files/docs/report.pdf, and so on.</span>
app.get(<span class="hljs-string">'/files/*'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> {
  <span class="hljs-comment">// c.req.path will contain the full matched path</span>
  <span class="hljs-keyword">return</span> c.text(<span class="hljs-string">`You are accessing the file at: <span class="hljs-subst">${c.req.path}</span>`</span>)
})

<span class="hljs-comment">// The '?' makes the '/:format?' part of the URL optional</span>
<span class="hljs-comment">// This will match both /api/posts and /api/posts/json</span>
app.get(<span class="hljs-string">'/api/posts/:format?'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> format = c.req.param(<span class="hljs-string">'format'</span>)
  <span class="hljs-keyword">if</span> (format === <span class="hljs-string">'json'</span>) {
    <span class="hljs-keyword">return</span> c.json({ message: <span class="hljs-string">'Here are the posts in JSON format.'</span> })
  }
  <span class="hljs-keyword">return</span> c.text(<span class="hljs-string">'Here are the posts in plain text.'</span>)
})
</code></pre>
<h4 id="heading-3-how-to-group-routes-with-approute">3. How to Group Routes with <code>app.route()</code></h4>
<p>For larger applications, you should organize your routes into logical groups. The <code>app.route()</code> method is perfect for this. It allows you to create modular routers and mount them on a specific prefix.</p>
<p>Let's create a more complex API structure for a blog.</p>
<p><code>src/routes/posts.ts</code></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Hono } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono'</span>

<span class="hljs-comment">// Create a new router instance specifically for posts</span>
<span class="hljs-keyword">const</span> posts = <span class="hljs-keyword">new</span> Hono()

posts.get(<span class="hljs-string">'/'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> c.json({ posts: [] }))
posts.post(<span class="hljs-string">'/'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> c.json({ message: <span class="hljs-string">'Post created'</span> }, <span class="hljs-number">201</span>))
posts.get(<span class="hljs-string">'/:id'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> c.json({ post: { id: c.req.param(<span class="hljs-string">'id'</span>) } }))

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> posts
</code></pre>
<p><code>src/routes/authors.ts</code></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Hono } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono'</span>

<span class="hljs-keyword">const</span> authors = <span class="hljs-keyword">new</span> Hono()

authors.get(<span class="hljs-string">'/'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> c.json({ authors: [] }))
authors.get(<span class="hljs-string">'/:id'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> c.json({ author: { id: c.req.param(<span class="hljs-string">'id'</span>) } }))

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> authors
</code></pre>
<p><code>src/index.ts</code></p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { serve } <span class="hljs-keyword">from</span> <span class="hljs-string">'@hono/node-server'</span>
<span class="hljs-keyword">import</span> { Hono } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono'</span>
<span class="hljs-keyword">import</span> { appendTrailingSlash } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono/trailing-slash'</span>;
<span class="hljs-keyword">import</span> posts <span class="hljs-keyword">from</span> <span class="hljs-string">'./routes/posts.js'</span>
<span class="hljs-keyword">import</span> authors <span class="hljs-keyword">from</span> <span class="hljs-string">'./routes/authors.js'</span>

<span class="hljs-keyword">const</span> app = <span class="hljs-keyword">new</span> Hono()

app.use(appendTrailingSlash());

app.route(<span class="hljs-string">'/posts/'</span>, posts)
app.route(<span class="hljs-string">'/authors/'</span>, authors)

app.get(<span class="hljs-string">'/'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> {
  <span class="hljs-keyword">return</span> c.text(<span class="hljs-string">'Hello Hono!'</span>)
})

serve({
  fetch: app.fetch,
  port: <span class="hljs-number">3000</span>
}, <span class="hljs-function">(<span class="hljs-params">info</span>) =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Server is running on http://localhost:<span class="hljs-subst">${info.port}</span>`</span>)
})
</code></pre>
<p>This pattern keeps your main <code>index.ts</code> file clean and makes your application much easier to navigate and maintain.</p>
<h2 id="heading-the-context-object-in-depth">The Context Object in Depth</h2>
<p>The <strong>Context</strong> (<code>c</code>) is the heart of Hono. It's an object that gets passed to every middleware and route handler, containing all the information related to the current request. It's essentially a container for the request (<code>c.req</code>) methods for creating a response (<code>c.json</code>, <code>c.html</code>, <code>c.text</code>), as well as a special property for passing data between middleware (<code>c.set</code> and <code>c.get</code>).</p>
<p>While this covers its most common and useful properties, the full Context object contains more. For a comprehensive list of all available properties and methods, you can refer to the official <a target="_blank" href="https://hono.dev/docs/api/context">Hono documentation</a>.</p>
<p>Let's explore how you can use the context object to pass data between middleware and handlers, a crucial technique for things like authentication.</p>
<p>The <code>c.set()</code> and <code>c.get()</code> methods allow you to store and retrieve typed data within the context of a single request.</p>
<p>Replace <code>src/index.ts</code> with this example for authentication:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Hono } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono'</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { Context, Next } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono'</span>

<span class="hljs-comment">// Define a type for the variables we will store in the context</span>
<span class="hljs-keyword">type</span> AppVariables = {
  user: {
    id: <span class="hljs-built_in">string</span>
    name: <span class="hljs-built_in">string</span>
    roles: <span class="hljs-built_in">string</span>[]
  }
}

<span class="hljs-comment">// Use a generic to tell our Hono app about the variables type</span>
<span class="hljs-keyword">const</span> app = <span class="hljs-keyword">new</span> Hono&lt;{ Variables: AppVariables }&gt;()

<span class="hljs-comment">// Middleware to "authenticate" a user from a header</span>
<span class="hljs-keyword">const</span> authMiddleware = <span class="hljs-keyword">async</span> (c: Context, next: Next) =&gt; {
  <span class="hljs-keyword">const</span> userId = c.req.header(<span class="hljs-string">'X-User-ID'</span>)
  <span class="hljs-keyword">if</span> (!userId) {
    <span class="hljs-keyword">return</span> c.json({ error: <span class="hljs-string">'Missing X-User-ID header'</span> }, <span class="hljs-number">401</span>)
  }

  <span class="hljs-comment">// In a real app, you would fetch this from a database</span>
  <span class="hljs-keyword">const</span> user = {
    id: userId,
    name: <span class="hljs-string">'Jane Doe'</span>,
    roles: [<span class="hljs-string">'admin'</span>, <span class="hljs-string">'editor'</span>],
  }

  <span class="hljs-comment">// Use c.set() to attach the user data to the context</span>
  c.set(<span class="hljs-string">'user'</span>, user)

  <span class="hljs-keyword">await</span> next()
}

app.get(<span class="hljs-string">'/admin/dashboard'</span>, authMiddleware, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> {
  <span class="hljs-comment">// Use c.get() to retrieve the typed user data</span>
  <span class="hljs-keyword">const</span> user = c.get(<span class="hljs-string">'user'</span>)

  <span class="hljs-keyword">if</span> (!user.roles.includes(<span class="hljs-string">'admin'</span>)) {
    <span class="hljs-keyword">return</span> c.json({ error: <span class="hljs-string">'Forbidden'</span> }, <span class="hljs-number">403</span>)
  }

  <span class="hljs-keyword">return</span> c.json({
    message: <span class="hljs-string">`Welcome to the admin dashboard, <span class="hljs-subst">${user.name}</span>!`</span>,
    userId: user.id,
  })
})

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> app
</code></pre>
<p>Let's break down the important parts of the code above.</p>
<ul>
<li><p><strong>Typed context variables</strong>: We define a TypeScript type <code>AppVariables</code> and pass it as a generic to our Hono app <code>new Hono&lt;{ Variables: AppVariables }&gt;()</code>. This is a powerful feature that gives us full type-safety for our context variables, preventing typos and ensuring that the data we store and retrieve is exactly what we expect it to be.</p>
</li>
<li><p><strong>Custom middleware</strong>: The <code>authMiddleware</code> is a custom function that runs before our route handler. It inspects the incoming request's headers (<code>c.req.header('X-User-ID')</code>).</p>
</li>
<li><p><strong>Storing data</strong>: If a valid header is found, the middleware uses <code>c.set('user', user)</code> to store the user object on the context. This data is now available to any subsequent middleware or route handler for the same request.</p>
</li>
<li><p><strong>Retrieving data</strong>: The route handler <code>app.get('/admin/dashboard', ...)</code> then uses <code>c.get('user')</code> to retrieve the user object. Hono's type system ensures that <code>c.get('user')</code> returns a variable with the type <code>{ id: string; name: string; roles: string[]; }</code>.</p>
</li>
<li><p><strong>Flow control</strong>: If the user is missing or doesn't have the "admin" role, the middleware or handler can immediately send an error response using <code>c.json()</code> and a status code, preventing the request from proceeding further.</p>
</li>
</ul>
<p>Now, run <code>npm run dev</code>.</p>
<p>You can test with <code>curl</code> (add header):</p>
<pre><code class="lang-bash">curl -H <span class="hljs-string">"X-User-ID: 123"</span> http://localhost:3000/admin/dashboard
</code></pre>
<p>This will return a welcome message.</p>
<p>Without the header:</p>
<pre><code class="lang-bash">curl http://localhost:3000/admin/dashboard
</code></pre>
<p>This will return a <code>401</code> error.</p>
<p>This demonstrates how to pass typed data securely and efficiently between middleware and route handlers.</p>
<h2 id="heading-how-to-use-advanced-features-for-production-apps">How to Use Advanced Features for Production Apps</h2>
<p>Now we're ready to tackle the features you'll use every day in production: advanced middleware, data validation, and building full-stack applications.</p>
<h3 id="heading-how-to-use-advanced-middleware-patterns">How to Use Advanced Middleware Patterns</h3>
<p>Hono has a powerful set of built-in middleware, including JWT and caching. These are not separate libraries you have to install, but rather functions that come with the Hono package itself.</p>
<p><strong>Step 1:</strong> Replace <code>src/index.ts</code> with this example for JWT and caching:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Hono } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono'</span>
<span class="hljs-keyword">import</span> { serve } <span class="hljs-keyword">from</span> <span class="hljs-string">'@hono/node-server'</span>
<span class="hljs-keyword">import</span> { jwt, sign } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono/jwt'</span>

<span class="hljs-keyword">const</span> app = <span class="hljs-keyword">new</span> Hono()
<span class="hljs-keyword">const</span> SECRET = <span class="hljs-string">'my-secret-key'</span> <span class="hljs-comment">// Use an environment variable in production!</span>

<span class="hljs-comment">// Create a simple in-memory cache store</span>
<span class="hljs-keyword">const</span> cacheStore = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>();

<span class="hljs-comment">// Custom caching middleware for Node.js</span>
app.use(<span class="hljs-string">'/api/public-data'</span>, <span class="hljs-keyword">async</span> (c, next) =&gt; {
  <span class="hljs-keyword">const</span> cacheKey = c.req.url;

  <span class="hljs-comment">// Check if the response is in our cache</span>
  <span class="hljs-keyword">if</span> (cacheStore.has(cacheKey)) {
    <span class="hljs-keyword">const</span> cachedItem = cacheStore.get(cacheKey);
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Serving from custom in-memory cache.'</span>);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> Response(cachedItem.body, { headers: cachedItem.headers });
  }

  <span class="hljs-comment">// If not in cache, proceed to the route handler</span>
  <span class="hljs-keyword">await</span> next();

  <span class="hljs-comment">// After the handler returns, clone and store the response</span>
  <span class="hljs-keyword">if</span> (c.res) {
    <span class="hljs-keyword">const</span> newResponse = c.res.clone();
    <span class="hljs-keyword">const</span> body = <span class="hljs-keyword">await</span> newResponse.text();
    <span class="hljs-keyword">const</span> headers = <span class="hljs-built_in">Object</span>.fromEntries(newResponse.headers.entries());
    cacheStore.set(cacheKey, { body, headers });
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Storing response in custom in-memory cache.'</span>);
  }
});

<span class="hljs-comment">// Login to get a JWT</span>
app.post(<span class="hljs-string">'/login'</span>, <span class="hljs-keyword">async</span> (c) =&gt; {
  <span class="hljs-keyword">const</span> { username } = <span class="hljs-keyword">await</span> c.req.json()
  <span class="hljs-keyword">if</span> (username === <span class="hljs-string">'admin'</span>) {
    <span class="hljs-keyword">const</span> payload = {
      sub: username,
      role: <span class="hljs-string">'admin'</span>,
      exp: <span class="hljs-built_in">Math</span>.floor(<span class="hljs-built_in">Date</span>.now() / <span class="hljs-number">1000</span>) + <span class="hljs-number">60</span> * <span class="hljs-number">5</span>, <span class="hljs-comment">// 5 minutes expiration</span>
    }
    <span class="hljs-keyword">const</span> token = <span class="hljs-keyword">await</span> sign(payload, SECRET)
    <span class="hljs-keyword">return</span> c.json({ token })
  }
  <span class="hljs-keyword">return</span> c.json({ error: <span class="hljs-string">'Invalid credentials'</span> }, <span class="hljs-number">401</span>)
})

<span class="hljs-comment">// Protected route</span>
app.get(
  <span class="hljs-string">'/api/protected'</span>,
  jwt({ secret: SECRET }),
  <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> {
    <span class="hljs-keyword">const</span> payload = c.get(<span class="hljs-string">'jwtPayload'</span>)
    <span class="hljs-keyword">return</span> c.json({ message: <span class="hljs-string">'You have access!'</span>, payload })
  }
)

<span class="hljs-comment">// Cached route</span>
app.get(
  <span class="hljs-string">'/api/public-data'</span>,
  <span class="hljs-keyword">async</span> (c) =&gt; {
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">'Executing handler with delay...'</span>);
    <span class="hljs-keyword">await</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Promise</span>(<span class="hljs-function"><span class="hljs-params">resolve</span> =&gt;</span> <span class="hljs-built_in">setTimeout</span>(resolve, <span class="hljs-number">1000</span>)) <span class="hljs-comment">// Simulate a delay</span>
    <span class="hljs-keyword">return</span> c.json({ data: <span class="hljs-string">'This is some public data that rarely changes.'</span> })
  }
)

serve({ fetch: app.fetch, port: <span class="hljs-number">3000</span> }, <span class="hljs-function">(<span class="hljs-params">info</span>) =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Server is running on http://localhost:<span class="hljs-subst">${info.port}</span>`</span>)
})
</code></pre>
<p>The code above shows two different types of middleware in action.</p>
<p>First, <strong>JWT middleware</strong> (<code>jwt</code>) is a powerful way to secure your routes. When we call <code>jwt({ secret: SECRET })</code>, we're telling Hono to check for a valid JWT in the <code>Authorization</code> header of the incoming request. If a valid token is found, it decodes the payload and attaches it to the context, where we can retrieve it with <code>c.get('jwtPayload')</code>. If no token is found or if the token is invalid, the middleware automatically stops the request and returns a <code>401 Unauthorized</code> error.</p>
<p>We also have <strong>Custom Cache Middleware</strong> which demonstrates the power of Hono's middleware system for in-memory caching. The middleware first checks an in-memory <code>Map</code> to see if a response for the current URL already exists. If it does, it immediately returns the cached response, preventing the route handler from ever being executed. If the response is not in the cache, it allows the request to continue to the handler. After the handler returns, the middleware intercepts the response and stores a copy in the cache before sending it back to the client. This is a robust and reliable pattern for Node.js environments.</p>
<p><strong>Step 2:</strong> Run <code>npm run dev</code>.</p>
<p><strong>Step 3:</strong> Test the login endpoint with <code>curl</code>:</p>
<p>First, let's test the login endpoint to get a JWT. Open a new terminal and run the following command. The command sends a <code>POST</code> request to the <code>/login</code> endpoint with <code>username: "admin"</code> in the request body.</p>
<pre><code class="lang-bash">curl -X POST http://localhost:3000/login -H <span class="hljs-string">"Content-Type: application/json"</span> -d <span class="hljs-string">'{"username": "admin"}'</span>
</code></pre>
<p>This will return a JSON object with a JWT. Copy this token for the next step.</p>
<p>Now, let's test the protected route. We'll use the token we just received in the <code>Authorization</code> header. Replace <code>&lt;your_jwt_token&gt;</code> with the token you copied.</p>
<pre><code class="lang-bash">curl http://localhost:3000/api/protected -H <span class="hljs-string">"Authorization: Bearer &lt;your_jwt_token&gt;"</span>
</code></pre>
<p>You should get a success message with the decoded payload.</p>
<p>Finally, let's test the cached route. You’ll need to run a production build and run the file with <code>node</code> for this to work.</p>
<p>First, run the following command. The <code>1000</code> millisecond delay in the code will make this request take about a second.</p>
<pre><code class="lang-bash">curl -o /dev/null -s -w <span class="hljs-string">'Total: %{time_total}s\n'</span> http://localhost:3000/api/public-data
</code></pre>
<p>Immediately run the <strong>exact same command again</strong>. This time, the response will be almost instantaneous because our custom cache middleware served the response directly from its in-memory store, completely bypassing the <code>setTimeout</code> in the route handler. Run it a third time, and you'll see a similar near-instantaneous response.</p>
<p>Here's an example of what your terminal output should look like when testing the cache. The first request took around 1 second, but subsequent requests were a matter of milliseconds.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1757125753352/180cc4a7-f361-4966-a26f-a5d8251f77a4.png" alt="180cc4a7-f361-4966-a26f-a5d8251f77a4" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h3 id="heading-how-to-create-a-global-error-handler">How to Create a Global Error Handler</h3>
<p>You can define a single global error handler with <code>app.onError()</code>. This is useful for handling unexpected errors in a centralized way, such as validation failures.</p>
<p>Add the following code to your <code>src/index.ts</code>:</p>
<pre><code class="lang-typescript">app.get(<span class="hljs-string">'/users/:id'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> id = c.req.param(<span class="hljs-string">'id'</span>)
  <span class="hljs-keyword">if</span> (<span class="hljs-built_in">isNaN</span>(<span class="hljs-built_in">Number</span>(id))) {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'User ID must be a number.'</span>)
  }
  <span class="hljs-keyword">return</span> c.text(<span class="hljs-string">`User ID is <span class="hljs-subst">${id}</span>`</span>)
})

app.onError(<span class="hljs-function">(<span class="hljs-params">err, c</span>) =&gt;</span> {
  <span class="hljs-built_in">console</span>.error(<span class="hljs-string">`<span class="hljs-subst">${err}</span>`</span>)
  <span class="hljs-keyword">return</span> c.json({
    success: <span class="hljs-literal">false</span>,
    message: err.message,
  }, <span class="hljs-number">500</span>)
})
</code></pre>
<p>Now, if you visit <a target="_blank" href="http://localhost:3000/users/abc"><code>http://localhost:3000/users/abc</code></a>, you will get a JSON error response instead of an uncaught exception.</p>
<h3 id="heading-how-to-handle-validation-with-zod">How to Handle Validation with Zod</h3>
<p>For robust APIs, data validation is essential. Hono integrates seamlessly with <a target="_blank" href="https://zod.dev/">Zod</a>, a popular TypeScript-first schema validation library.</p>
<p><strong>Step 1:</strong> Install the necessary dependencies:</p>
<pre><code class="lang-bash">npm install zod @hono/zod-validator
</code></pre>
<p><strong>Step 2:</strong> Replace <code>src/index.ts</code> with the validation example:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Hono } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono'</span>
<span class="hljs-keyword">import</span> { serve } <span class="hljs-keyword">from</span> <span class="hljs-string">'@hono/node-server'</span>
<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">'zod'</span>
<span class="hljs-keyword">import</span> { zValidator } <span class="hljs-keyword">from</span> <span class="hljs-string">'@hono/zod-validator'</span>

<span class="hljs-keyword">const</span> app = <span class="hljs-keyword">new</span> Hono()

<span class="hljs-comment">// Define a Zod schema for the user creation data</span>
<span class="hljs-keyword">const</span> createUserSchema = z.object({
  username: z.string().min(<span class="hljs-number">3</span>).max(<span class="hljs-number">20</span>),
  email: z.string().email(),
  age: z.number().int().positive(),
  tags: z.array(z.string()).optional(),
})

app.post(
  <span class="hljs-string">'/users'</span>,
  zValidator(<span class="hljs-string">'json'</span>, createUserSchema), <span class="hljs-comment">// Use zValidator middleware</span>
  (c) =&gt; {
    <span class="hljs-comment">// The validated data is available on c.req.valid()</span>
    <span class="hljs-keyword">const</span> user = c.req.valid(<span class="hljs-string">'json'</span>)
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Creating user: <span class="hljs-subst">${user.username}</span> with email <span class="hljs-subst">${user.email}</span>`</span>)
    <span class="hljs-keyword">return</span> c.json({
      success: <span class="hljs-literal">true</span>,
      message: <span class="hljs-string">'User created successfully!'</span>,
      user: user,
    }, <span class="hljs-number">201</span>)
  }
)

serve({ fetch: app.fetch, port: <span class="hljs-number">3000</span> }, <span class="hljs-function">(<span class="hljs-params">info</span>) =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Server is running on http://localhost:<span class="hljs-subst">${info.port}</span>`</span>)
})
</code></pre>
<p>This is how the Zod validation is working:</p>
<ol>
<li><p>We first define a schema called <code>createUserSchema</code> using <code>z.object()</code>. This schema is a blueprint for the expected data structure. We use Zod's built-in methods like <code>z.string().min(3)</code>, <code>z.string().email()</code>, and <code>z.number().int().positive()</code> to specify validation rules for each property. For example, <code>username</code> must be a string between 3 and 20 characters, <code>email</code> must be a valid email format, and <code>age</code> must be a positive integer.</p>
</li>
<li><p>We then apply the <a target="_blank" href="https://github.com/honojs/middleware/tree/main/packages/zod-validator"><code>zValidator</code></a> middleware to our route handler. The first argument, <code>'json'</code>, tells the middleware to validate the incoming request's JSON body. The second argument, <code>createUserSchema</code>, tells it which schema to use for the validation.</p>
</li>
<li><p>The <code>zValidator</code> middleware automatically does the heavy lifting. When a request hits the <code>/users</code> endpoint, it will parse the JSON body and attempt to validate it against <code>createUserSchema</code>. If the data is invalid (for example, the <code>email</code> is not in a valid format), the middleware will immediately stop the request and return a <code>400 Bad Request</code> status with a detailed error message, all without us having to write any manual checks.</p>
</li>
<li><p>If the data is valid, the middleware makes it available on the <code>Context</code> object, which we can access with <code>c.req.valid('json')</code>. Hono's type system ensures that this data is correctly typed according to the Zod schema, so we can use it safely in our handler.</p>
</li>
</ol>
<p><strong>Step 3:</strong> Run <code>npm run dev</code>.</p>
<p><strong>Step 4:</strong> Test with <code>curl</code> (valid data):</p>
<pre><code class="lang-bash">curl -X POST http://localhost:3000/users -H <span class="hljs-string">"Content-Type: application/json"</span> -d <span class="hljs-string">'{"username": "testuser", "email": "test@example.com", "age": 25}'</span>
</code></pre>
<p>This will return a success message.</p>
<p>Test with invalid data (for example, bad email):</p>
<pre><code class="lang-bash">curl -X POST http://localhost:3000/users -H <span class="hljs-string">"Content-Type: application/json"</span> -d <span class="hljs-string">'{"username": "testuser", "email": "invalid-email", "age": 25}'</span>
</code></pre>
<p>This will automatically return a <code>400</code> status with a detailed error message from Zod.</p>
<h3 id="heading-how-to-build-a-full-stack-app-with-jsx">How to Build a Full-Stack App with JSX</h3>
<p>Hono supports server-side rendering with JSX, allowing you to build full-stack applications without needing a separate framework.</p>
<p><strong>Step 1:</strong> Create <code>src/components/Layout.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { html } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono/html'</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> Layout = <span class="hljs-function">(<span class="hljs-params">props: { title: <span class="hljs-built_in">string</span>; children?: <span class="hljs-built_in">any</span> }</span>) =&gt;</span> html`<span class="xml">
  <span class="hljs-meta">&lt;!DOCTYPE <span class="hljs-meta-keyword">html</span>&gt;</span>
  <span class="hljs-tag">&lt;<span class="hljs-name">html</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">head</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">title</span>&gt;</span></span><span class="hljs-subst">${props.title}</span><span class="xml"><span class="hljs-tag">&lt;/<span class="hljs-name">title</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">style</span>&gt;</span><span class="css">
        <span class="hljs-selector-tag">body</span> { <span class="hljs-attribute">font-family</span>: sans-serif; <span class="hljs-attribute">background</span>: <span class="hljs-number">#f4f4f4</span>; <span class="hljs-attribute">color</span>: <span class="hljs-number">#333</span>; }
        <span class="hljs-selector-class">.container</span> { <span class="hljs-attribute">max-width</span>: <span class="hljs-number">800px</span>; <span class="hljs-attribute">margin</span>: <span class="hljs-number">2rem</span> auto; <span class="hljs-attribute">padding</span>: <span class="hljs-number">1rem</span>; <span class="hljs-attribute">background</span>: white; <span class="hljs-attribute">border-radius</span>: <span class="hljs-number">8px</span>; }
        <span class="hljs-selector-tag">header</span> { <span class="hljs-attribute">border-bottom</span>: <span class="hljs-number">1px</span> solid <span class="hljs-number">#ccc</span>; <span class="hljs-attribute">padding-bottom</span>: <span class="hljs-number">1rem</span>; }
        <span class="hljs-selector-tag">footer</span> { <span class="hljs-attribute">margin-top</span>: <span class="hljs-number">2rem</span>; <span class="hljs-attribute">text-align</span>: center; <span class="hljs-attribute">font-size</span>: <span class="hljs-number">0.8rem</span>; <span class="hljs-attribute">color</span>: <span class="hljs-number">#777</span>; }
      </span><span class="hljs-tag">&lt;/<span class="hljs-name">style</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">head</span>&gt;</span>
    <span class="hljs-tag">&lt;<span class="hljs-name">body</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"container"</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">header</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span></span><span class="hljs-subst">${props.title}</span><span class="xml"><span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">header</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">main</span>&gt;</span>
          </span><span class="hljs-subst">${props.children}</span><span class="xml">
        <span class="hljs-tag">&lt;/<span class="hljs-name">main</span>&gt;</span>
        <span class="hljs-tag">&lt;<span class="hljs-name">footer</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Powered by Hono<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">footer</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">body</span>&gt;</span>
  <span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
`</span>
</code></pre>
<p><strong>Step 2:</strong> Create <code>src/components/PostItem.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> PostItem = <span class="hljs-function">(<span class="hljs-params">props: { post: { id: <span class="hljs-built_in">number</span>; title: <span class="hljs-built_in">string</span>; author: <span class="hljs-built_in">string</span> } }</span>) =&gt;</span> (
  &lt;article style=<span class="hljs-string">"border-bottom: 1px solid #eee; padding: 1rem 0;"</span>&gt;
    &lt;h3&gt;&lt;a href={<span class="hljs-string">`/posts/<span class="hljs-subst">${props.post.id}</span>`</span>}&gt;{props.post.title}&lt;<span class="hljs-regexp">/a&gt;&lt;/</span>h3&gt;
    &lt;p&gt;&lt;em&gt;By {props.post.author}&lt;<span class="hljs-regexp">/em&gt;&lt;/</span>p&gt;
  &lt;/article&gt;
)
</code></pre>
<p><strong>Step 3:</strong> Update <code>src/index.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { Hono } <span class="hljs-keyword">from</span> <span class="hljs-string">'hono'</span>
<span class="hljs-keyword">import</span> { serve } <span class="hljs-keyword">from</span> <span class="hljs-string">'@hono/node-server'</span>
<span class="hljs-keyword">import</span> { Layout } <span class="hljs-keyword">from</span> <span class="hljs-string">'./components/Layout'</span>
<span class="hljs-keyword">import</span> { PostItem } <span class="hljs-keyword">from</span> <span class="hljs-string">'./components/PostItem'</span>

<span class="hljs-keyword">const</span> app = <span class="hljs-keyword">new</span> Hono()

<span class="hljs-comment">// Mock data</span>
<span class="hljs-keyword">const</span> posts = [
  { id: <span class="hljs-number">1</span>, title: <span class="hljs-string">'Getting Started with Hono'</span>, author: <span class="hljs-string">'Alice'</span> },
  { id: <span class="hljs-number">2</span>, title: <span class="hljs-string">'Advanced Middleware Patterns'</span>, author: <span class="hljs-string">'Bob'</span> },
  { id: <span class="hljs-number">3</span>, title: <span class="hljs-string">'Deploying Hono to the Edge'</span>, author: <span class="hljs-string">'Charlie'</span> },
]

app.get(<span class="hljs-string">'/'</span>, <span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> {
  <span class="hljs-keyword">return</span> c.html(
    &lt;Layout title=<span class="hljs-string">"My Hono Blog"</span>&gt;
      &lt;h2&gt;Recent Posts&lt;/h2&gt;
      {posts.length &gt; <span class="hljs-number">0</span>
        ? posts.map(<span class="hljs-function"><span class="hljs-params">post</span> =&gt;</span> &lt;PostItem post={post} /&gt;)
        : &lt;p&gt;No posts yet!&lt;/p&gt;
      }
    &lt;/Layout&gt;
  )
})

serve({ fetch: app.fetch, port: <span class="hljs-number">3000</span> }, <span class="hljs-function">(<span class="hljs-params">info</span>) =&gt;</span> {
  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">`Server is running on http://localhost:<span class="hljs-subst">${info.port}</span>`</span>)
})
</code></pre>
<p>Make sure to update the <code>dev</code> script in your <code>package.json</code> file to have <code>src/index.tsx</code> as the starting point.</p>
<pre><code class="lang-json"><span class="hljs-string">"dev"</span>: <span class="hljs-string">"tsx watch src/index.tsx"</span>
</code></pre>
<p><strong>Step 4:</strong> Run <code>npm run dev</code> and visit <a target="_blank" href="http://localhost:3000"><code>http://localhost:3000</code></a>. You will see a fully rendered blog page with the list of posts.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1756952439130/27ee63cc-6d60-4372-9634-4d1eadf33f32.png" alt="Blog page with list of posts" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h2 id="heading-deployment-guide-for-hono">Deployment Guide for Hono</h2>
<p>You have built your application, and now it's time to share it with the world. Here’s how you can deploy your Hono app to some of the most popular platforms.</p>
<h3 id="heading-how-to-deploy-to-nodejs">How to Deploy to Node.js</h3>
<p>For a traditional server environment, you can use the <code>@hono/node-server</code> adapter and a process manager like <code>pm2</code> for production.</p>
<p><code>src/index.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { serve } <span class="hljs-keyword">from</span> <span class="hljs-string">'@hono/node-server'</span>
<span class="hljs-keyword">import</span> app <span class="hljs-keyword">from</span> <span class="hljs-string">'./app'</span> <span class="hljs-comment">// Assuming your Hono app is in app.ts</span>

serve({ fetch: app.fetch, port: <span class="hljs-number">3000</span> })
</code></pre>
<p>You will then build your TypeScript to JavaScript and run <code>pm2 start dist/index.js</code> to run it in the background.</p>
<h3 id="heading-how-to-deploy-to-cloudflare-workers">How to Deploy to Cloudflare Workers</h3>
<p>Hono's true power lies in its portability. The <code>create hono</code> command can set up a project specifically for Cloudflare Workers.</p>
<p>Run the following command and select the <code>cloudflare-workers</code> template:</p>
<pre><code class="lang-bash">npm create hono@latest my-app-hono-cloudflare-worker

create-hono version 0.19.2
✔ Using target directory … my-app-hono-cloudflare-worker
? Which template <span class="hljs-keyword">do</span> you want to use?
  aws-lambda
  bun
❯ cloudflare-workers
  cloudflare-workers+vite
  deno
  fastly
  lambda-edge
</code></pre>
<p>The setup process is identical to the Node.js example, but the project structure is optimized for Cloudflare.</p>
<p>Once the project is set up, you only need to type one command to deploy your application to Cloudflare:</p>
<pre><code class="lang-bash">wrangler deploy
</code></pre>
<p>This command will prompt you to log in to your Cloudflare account and will handle the entire deployment process automatically.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>You've made it! We’ve covered a lot in this guide. You started with a professional project setup and moved all the way through advanced routing, context management, complex middleware patterns, robust data validation, and full-stack JSX components.</p>
<p>You now have the knowledge and the tools to build serious, production-ready applications with Hono. Its simple API doesn't limit its power. Rather, it enhances it by getting out of your way and letting you focus on building great features. And with its helpful portability, you can be confident that the application you build today can be deployed to the platforms of tomorrow.</p>
<p>The web development ecosystem will continue to evolve, but by building on Web Standards, Hono is a framework that's built to last.</p>
<p>To continue your journey, I highly recommend exploring the official <a target="_blank" href="https://hono.dev/docs/">Hono documentation</a>, which is full of even more examples and guides.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
