<?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[ Node.js - 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[ Node.js - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Fri, 13 Nov 2026 00:24:14 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/nodejs/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ What Happens When You Upload a Large File & Generate a Temporary Download Link ]]>
                </title>
                <description>
                    <![CDATA[ Sending a 5 GB video file might seem simple. You select the file, click Upload, wait for the progress bar to finish, and share a link with someone. But there are several things happening in the backgr ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-file-transfer-services-work/</link>
                <guid isPermaLink="false">6af31a20042fa071f004461c</guid>
                
                    <category>
                        <![CDATA[ webdev ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Programming Blogs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Reetain Raina ]]>
                </dc:creator>
                <pubDate>Tue, 10 Nov 2026 13:20:32 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/5ab5b73d-ffb5-4c71-91b7-648b69bb79b7.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Sending a 5 GB video file might seem simple. You select the file, click Upload, wait for the progress bar to finish, and share a link with someone. But there are several things happening in the background while the file is being uploaded.</p>
<p>The file has to travel across a network, reach a server, and be stored somewhere before another person can download it. The service also needs to make sure that the recipient can access the file without accessing the sender's account. If the internet connection drops halfway through the upload, it needs a way to continue without sending the entire file again.</p>
<p>File transfer services use HTTP requests, storage systems, databases, encryption, and background processes to manage these tasks. But how does a website actually handle a file that's several gigabytes in size? Does it upload everything in one request? Where does the file go after the upload finishes? And what happens when its download link expires?</p>
<p>In this article, we'll look at how file transfer services handle large uploads, store files, generate download links, and manage interrupted connections and expired transfers. Then, once you understand the theory behind how it all works, we'll also build a basic chunked file uploader using Node.js to see some of these processes in action.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><a href="#heading-why-email-attachments-fail-smtp-wasnt-designed-for-large-files">Why Email Attachments Fail: SMTP Wasn't Designed for Large Files</a><ul>
<li><a href="#heading-smtp-and-attachment-size-limits">SMTP and Attachment Size Limits</a></li>
<li><a href="#heading-the-base64-problem">The Base64 Problem</a></li>
</ul>
</li>
<li><a href="#heading-the-core-architecture-what-happens-behind-a-file-transfer-service">The Core Architecture: What Happens Behind a File Transfer Service?</a><ul>
<li><a href="#heading-the-client">The Client</a></li>
<li><a href="#heading-the-upload-server">The Upload Server</a></li>
<li><a href="#heading-the-storage-layer">The Storage Layer</a></li>
<li><a href="#heading-the-metadata-database">The Metadata Database</a></li>
<li><a href="#heading-the-download-server">The Download Server</a></li>
</ul>
</li>
<li><a href="#heading-chunked-and-resumable-uploads-how-large-files-survive-bad-internet-connections">Chunked and Resumable Uploads: How Large Files Survive Bad Internet Connections</a><ul>
<li><a href="#heading-breaking-a-large-file-into-smaller-chunks">Breaking a Large File Into Smaller Chunks</a></li>
<li><a href="#heading-what-happens-when-the-internet-disconnects">What Happens When the Internet Disconnects?</a></li>
<li><a href="#heading-checksums-making-sure-nothing-gets-corrupted">Checksums: Making Sure Nothing Gets Corrupted</a></li>
</ul>
</li>
<li><a href="#heading-storage-and-link-generation-where-does-your-file-actually-go">Storage and Link Generation: Where Does Your File Actually Go?</a><ul>
<li><a href="#heading-why-services-use-object-storage">Why Services Use Object Storage</a></li>
<li><a href="#heading-how-a-download-link-is-created">How a Download Link Is Created</a></li>
<li><a href="#heading-why-are-these-links-difficult-to-guess">Why Are These Links Difficult to Guess?</a></li>
</ul>
</li>
<li><a href="#heading-expiry-and-cleanup-what-happens-when-a-transfer-expires">Expiry and Cleanup: What Happens When a Transfer Expires?</a><ul>
<li><a href="#heading-time-to-live-ttl">Time to Live (TTL)</a></li>
<li><a href="#heading-automated-cleanup-jobs">Automated Cleanup Jobs</a></li>
<li><a href="#heading-download-limits">Download Limits</a></li>
</ul>
</li>
<li><a href="#heading-security-layers-protecting-files-during-transfer-and-storage">Security Layers: Protecting Files During Transfer and Storage</a><ul>
<li><a href="#heading-tls-encryption-in-transit">TLS Encryption in Transit</a></li>
<li><a href="#heading-encryption-at-rest">Encryption at Rest</a></li>
<li><a href="#heading-password-protected-links">Password-Protected Links</a></li>
</ul>
</li>
<li><a href="#heading-real-world-examples-how-popular-file-transfer-services-handle-file-sharing">Real-World Examples: How Popular File Transfer Services Handle File Sharing</a><ul>
<li><a href="#heading-wetransfer-temporary-transfers-and-password-protection">WeTransfer: Temporary Transfers and Password Protection</a></li>
<li><a href="#heading-dropbox-transfer-sending-files-without-collaboration">Dropbox Transfer: Sending Files Without Collaboration</a></li>
<li><a href="#heading-send-anywhere-direct-transfers-and-temporary-links">Send Anywhere: Direct Transfers and Temporary Links</a></li>
<li><a href="#heading-filemail-large-transfers-and-expiration-controls">Filemail: Large Transfers and Expiration Controls</a></li>
</ul>
</li>
<li><a href="#heading-hands-on-build-a-minimal-chunked-file-uploader-using-nodejs">Hands-On: Build a Minimal Chunked File Uploader Using Node.js</a><ul>
<li><a href="#heading-step-1-set-up-the-nodejs-project">Step 1: Set Up the Node.js Project</a></li>
<li><a href="#heading-step-2-divide-the-file-into-smaller-chunks">Step 2: Divide the File Into Smaller Chunks</a></li>
<li><a href="#heading-step-3-create-an-upload-endpoint-in-express">Step 3: Create an Upload Endpoint in Express</a></li>
<li><a href="#heading-step-4-reassemble-the-original-file">Step 4: Reassemble the Original File</a></li>
<li><a href="#heading-step-5-test-the-upload">Step 5: Test the Upload</a></li>
</ul>
</li>
<li><a href="#heading-conclusion-a-simple-link-hides-a-lot-of-engineering">Conclusion: A Simple Link Hides a Lot of Engineering</a></li>
</ul>
<h2 id="heading-why-email-attachments-fail-smtp-wasnt-designed-for-large-files">Why Email Attachments Fail: SMTP Wasn't Designed for Large Files</h2>
<p>Before looking at dedicated file transfer services, it helps to understand why we can't just attach massive files to an email. Sending large files through email is fundamentally flawed because the underlying architecture was built for text, not high-resolution media.</p>
<h3 id="heading-smtp-and-attachment-size-limits">SMTP and Attachment Size Limits</h3>
<p>Simple Mail Transfer Protocol (SMTP) handles the transmission of email messages across the internet. Because servers have limited bandwidth and storage, email providers impose strict attachment size limits, usually capping out around 25 MB. However, these limits apply to the encoded message size, not just the original file sitting on your hard drive.</p>
<h3 id="heading-the-base64-problem">The Base64 Problem</h3>
<p>Email attachments are commonly encoded using MIME and Base64 formats. Base64 is necessary because it converts binary data into text-compatible characters that email servers can safely route. While this solves the transmission problem, it creates a massive efficiency issue: this encoding increases the data size by approximately 33%.</p>
<p>You can read the foundational specifications for this in <a href="https://datatracker.ietf.org/doc/html/rfc2045"><u>RFC 2045</u></a>, which defines MIME's Base64 transfer encoding. In practice, this means a 30 MB video file will require roughly 40 MB of space before additional message overhead is even calculated, guaranteeing it will bounce back from most email servers.</p>
<h2 id="heading-the-core-architecture-what-happens-behind-a-file-transfer-service">The Core Architecture: What Happens Behind a File Transfer Service?</h2>
<p>To bypass the limitations of email, file transfer services use a specialized, decoupled architecture. Having built a few of these systems myself, I believe establishing a clear mental model of the five major components is crucial for understanding the flow of data.</p>
<h3 id="heading-the-client">The Client</h3>
<p>The journey begins in the client, which is usually a web browser or a desktop application where the sender selects a file. Modern browsers use JavaScript's File API to access file metadata, read the raw contents and intelligently divide the file into smaller, manageable parts before any data hits the network.</p>
<h3 id="heading-the-upload-server">The Upload Server</h3>
<p>Once the client starts sending data, the upload server steps in. This server receives the upload requests, authenticates the user, validates incoming data and coordinates the entire upload process. It acts as the traffic cop, ensuring that chunks of data are arriving in the correct format.</p>
<h3 id="heading-the-storage-layer">The Storage Layer</h3>
<p>Traditional application servers are terrible at holding massive files, so the storage layer handles the actual heavy lifting. Uploaded files are typically routed directly to object storage solutions. This allows the system to scale infinitely without crashing the primary upload servers.</p>
<h3 id="heading-the-metadata-database">The Metadata Database</h3>
<p>While the physical file goes to object storage, the application needs a way to remember what that file is. The metadata database stores critical information, including the original filename, file size, content type, the specific object storage key, a unique transfer identifier and the exact expiration timestamp.</p>
<h3 id="heading-the-download-server">The Download Server</h3>
<p>When someone opens a shared link, the download server takes over. It checks the database to verify permissions and expiration dates, and if everything is valid, it retrieves the stored object from the storage layer for delivery.</p>
<h2 id="heading-chunked-and-resumable-uploads-how-large-files-survive-bad-internet-connections">Chunked and Resumable Uploads: How Large Files Survive Bad Internet Connections</h2>
<p>Transferring a 5 GB file in one continuous network request is a disaster waiting to happen. If a connection drops at 99%, a single-request architecture forces the user to start all over again.</p>
<h3 id="heading-breaking-a-large-file-into-smaller-chunks">Breaking a Large File Into Smaller Chunks</h3>
<p>To solve this, modern clients divide large files into smaller parts, such as 10 MB chunks. Each chunk is uploaded to the server as an independent HTTP request. The server meticulously tracks which chunks have arrived. Some advanced systems even allow multiple chunks to upload simultaneously in parallel, drastically reducing the overall waiting time.</p>
<p>It's important to note that chunked uploads are an architectural pattern, which is entirely different from HTTP chunked transfer encoding (a mechanism for framing an HTTP message body).</p>
<h3 id="heading-what-happens-when-the-internet-disconnects">What Happens When the Internet Disconnects?</h3>
<p>Suppose 80% of a massive file has successfully uploaded when your Wi-Fi randomly disconnects. Without resumable uploads, that progress is lost. With resumable architecture, the client reconnects and asks the server for the upload offset, the exact number of bytes successfully received and stored. The client then seamlessly continues uploading from that exact position.</p>
<h3 id="heading-checksums-making-sure-nothing-gets-corrupted">Checksums: Making Sure Nothing Gets Corrupted</h3>
<p>Network transfers can sometimes flip bits, resulting in corrupted files. To prevent this, clients generate a cryptographic hash (checksum) for an individual chunk before sending it. The server receives the chunk, calculates its own hash, and compares the two. If the checksums don't match, the server rejects the chunk and asks the client to try again.</p>
<p>If you're looking to implement this, the <a href="https://tus.io/"><u>TUS Resumable Upload Protocol</u></a> is a fantastic, standardized reference for managing upload offsets and checksum verification. Also, the <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html"><u>Amazon S3 Multipart Upload Documentation</u></a> provides an industry-standard blueprint for handling parallel transfers and retrying failed parts.</p>
<h2 id="heading-storage-and-link-generation-where-does-your-file-actually-go">Storage and Link Generation: Where Does Your File Actually Go?</h2>
<p>Once every chunk is verified and reassembled, the service needs a secure place to keep the file until the recipient is ready to download it.</p>
<h3 id="heading-why-services-use-object-storage">Why Services Use Object Storage</h3>
<p>In my opinion, attempting to store gigabytes of user uploads on a traditional local server directory is architectural malpractice. Instead, services rely on object storage. Files are stored as flat objects and each object is assigned a unique key. Object storage is highly durable and scales quite easily to accommodate millions of massive files simultaneously.</p>
<h3 id="heading-how-a-download-link-is-created">How a Download Link Is Created</h3>
<p>The relationship between the stored file and its shareable link relies on the metadata database. When the upload finishes, the application saves the file's storage key in the database and generates a unique access token. The recipient receives a URL containing that specific token. When they click the link, the server reads the token, locates the relevant file in the database and checks the download permissions.</p>
<h3 id="heading-why-are-these-links-difficult-to-guess">Why Are These Links Difficult to Guess?</h3>
<p>A secure file transfer link relies heavily on high entropy, which is randomness that makes tokens mathematically near-impossible to predict. Also, secure systems use signed URLs. These are URLs containing cryptographic signatures that authorize access for a limited, strictly-enforced period.</p>
<p>As outlined in the <a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html"><u>OWASP Session Management Cheat Sheet</u></a>, a random link isn't automatically secure without appropriate access controls, expiration policies, and protection against token leakage.</p>
<h2 id="heading-expiry-and-cleanup-what-happens-when-a-transfer-expires">Expiry and Cleanup: What Happens When a Transfer Expires?</h2>
<p>File transfer services process petabytes of data, meaning they can't store uploaded files forever without going bankrupt. They rely on aggressive lifecycle management.</p>
<h3 id="heading-time-to-live-ttl">Time to Live (TTL)</h3>
<p>Every transfer is assigned a Time to Live (TTL) or expiration timestamp. Once that time passes, the database instantly invalidates the transfer identifier, ensuring that the download link no longer authorizes access.</p>
<h3 id="heading-automated-cleanup-jobs">Automated Cleanup Jobs</h3>
<p>Because link expiration and physical deletion aren't necessarily simultaneous, background workers or scheduled cron jobs handle the actual cleanup. These workers periodically scan the database to find expired transfers, mark them as permanently unavailable, delete the associated multi-gigabyte objects from the storage layer, and finally remove the outdated metadata.</p>
<h3 id="heading-download-limits">Download Limits</h3>
<p>Some services also allow senders to limit how many times a file can be downloaded. Once the download count reaches the threshold, the link behaves exactly as if the TTL had expired, offering an excellent way to control unauthorized distribution.</p>
<h2 id="heading-security-layers-protecting-files-during-transfer-and-storage">Security Layers: Protecting Files During Transfer and Storage</h2>
<p>Security in file transfers is never about a single magic feature. It requires multiple layers of defense to keep data safe from interception or theft.</p>
<h3 id="heading-tls-encryption-in-transit">TLS Encryption in Transit</h3>
<p>When data travels between the sender, the transfer service, and the recipient, it's heavily encrypted. Modern platforms use HTTPS backed by strict TLS protocols to ensure no one can snoop on the data packets mid-route.</p>
<p>For a deep dive into how this is configured at an enterprise level, <a href="https://csrc.nist.gov/publications/detail/sp/800-52/rev-2/final"><u>NIST SP 800-52 Rev. 2</u></a> offers exhaustive guidelines for TLS implementations.</p>
<h3 id="heading-encryption-at-rest">Encryption at Rest</h3>
<p>Once the file arrives at the storage layer, encryption at rest ensures that even if a bad actor physically accessed the server hardware, they would only find scrambled, unreadable data. It's important to note that encryption at rest isn't the same as end to end encryption. The service provider still manages the encryption keys in a standard setup.</p>
<h3 id="heading-password-protected-links">Password-Protected Links</h3>
<p>To prevent unauthorized users from accessing a valid URL, senders can add a password. This creates a secondary access-control layer that must be cleared before the server will even attempt to fetch the file from object storage. Additional safeguards, like rate-limiting against automated password guessing, ensure this layer remains effective.</p>
<h2 id="heading-real-world-examples-how-popular-file-transfer-services-handle-file-sharing">Real-World Examples: How Popular File Transfer Services Handle File Sharing</h2>
<p>Seeing how established companies implement these concepts helps bridge the gap between theory and practice.</p>
<h3 id="heading-wetransfer-temporary-transfers-and-password-protection">WeTransfer: Temporary Transfers and Password Protection</h3>
<p>WeTransfer is built entirely around temporary shareable links. Transfers have strict expiration timelines and premium users can enforce password protection. According to their security documentation, they utilize standard TLS encryption during transit and AES-256 encryption at rest to secure files on their servers.</p>
<h3 id="heading-dropbox-transfer-sending-files-without-collaboration">Dropbox Transfer: Sending Files Without Collaboration</h3>
<p>Unlike a standard shared folder, Dropbox Transfer creates standalone downloadable links. Recipients don't need a Dropbox account to download the payload. They utilize configurable expiration dates, passwords, and automated download notifications, with maximum file size limits dictated by the sender's specific subscription plan.</p>
<h3 id="heading-send-anywhere-direct-transfers-and-temporary-links">Send Anywhere: Direct Transfers and Temporary Links</h3>
<p>Send Anywhere takes a fascinating hybrid approach. Its six-digit key transfer mechanism operates in real-time for one-time use within a 10-minute window, often leveraging peer to peer connections. Conversely, its standard Share Link option falls back to a server-based architecture, uploading files and keeping the link valid for up to 48 hours.</p>
<h3 id="heading-filemail-large-transfers-and-expiration-controls">Filemail: Large Transfers and Expiration Controls</h3>
<p>Offering robust email and shareable-link delivery options, Filemail handles massive payloads easily. They provide highly configurable expiration controls and rigorous password protection. Their free service tier currently allows a 5 GB transfer limit, holding the data securely for a maximum seven-day storage period before automated cleanup takes over.</p>
<h2 id="heading-hands-on-build-a-minimal-chunked-file-uploader-using-nodejs">Hands-On: Build a Minimal Chunked File Uploader Using Node.js</h2>
<p>Understanding the theory is great, but building it yourself proves how it actually works. Let's create a simple application that divides a file into smaller chunks in the browser, uploads them to a Node.js server and stitches them back together.</p>
<h3 id="heading-step-1-set-up-the-nodejs-project">Step 1: Set Up the Node.js Project</h3>
<p>You'll need Node.js, the Express framework, and multer for handling incoming file chunks. The built-in fs module will handle our file operations. First, create a Node project and install the required dependencies:</p>
<pre><code class="language-shell">mkdir file-transfer
cd file-transfer
npm init -y
npm install express multer
</code></pre>
<h3 id="heading-step-2-divide-the-file-into-smaller-chunks">Step 2: Divide the File Into Smaller Chunks</h3>
<p>First, create a <code>public</code> directory inside your project folder if it doesn't already exist. Then, create a file named <code>upload.js</code> inside that directory. We'll use the browser's JavaScript File API and its <code>slice()</code> method to read smaller portions of a file without loading the entire 5 GB payload into your computer's RAM.</p>
<pre><code class="language-javascript">const chunkSize = 5 * 1024 * 1024;

async function uploadFile(file) {
    const totalChunks = Math.ceil(file.size / chunkSize);
    const uploadId = crypto.randomUUID();

    for (let index = 0; index &lt; totalChunks; index++) {
        const chunk = file.slice(
            index * chunkSize,
            (index + 1) * chunkSize
        );

        const formData = new FormData();
        formData.append("chunk", chunk);

        const response = await fetch(
            `/upload/${uploadId}/${index}/${totalChunks}`,
            {
                method: "POST",
                body: formData
            }
        );

        if (!response.ok) {
            throw new Error(`Chunk ${index} failed to upload`);
        }
    }

    const response = await fetch(`/complete/${uploadId}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
            totalChunks,
            filename: file.name
        })
    });

    if (!response.ok) {
        throw new Error("File reassembly failed");
    }
}
</code></pre>
<p>Here, <code>crypto.randomUUID()</code> generates a unique ID for the upload. The browser sends each chunk separately and uses the same ID to associate all the chunks with the original file.</p>
<h3 id="heading-step-3-create-an-upload-endpoint-in-express">Step 3: Create an Upload Endpoint in Express</h3>
<p>On the server side (<code>server.js</code>), we need an endpoint to receive and temporarily store each chunk. The server uses the upload ID and chunk index to determine where each part belongs, while the total number of chunks helps validate the upload. We use multer to parse the incoming multipart form data.</p>
<pre><code class="language-javascript">const express = require("express");
const multer = require("multer");
const fs = require("fs");
const path = require("path");
const { pipeline } = require("stream/promises");

const app = express();

app.use(express.static(path.join(__dirname, "public")));

const chunksDir = path.join(__dirname, "chunks");
const tempDir = path.join(chunksDir, "temp");

fs.mkdirSync(tempDir, { recursive: true });

const upload = multer({
    dest: tempDir,
    limits: { fileSize: 5 * 1024 * 1024 }
});

app.post(
    "/upload/:id/:index/:total",
    upload.single("chunk"),
    async (req, res) =&gt; {
        try {
            const { id, index, total } = req.params;

            if (!req.file) {
                return res.status(400).send("No chunk received");
            }

            if (
                !/^[a-f0-9-]{36}$/i.test(id) ||
                !Number.isInteger(Number(index)) ||
                !Number.isInteger(Number(total)) ||
                Number(total) &lt; 1 ||
                Number(total) &gt; 10000 ||
                Number(index) &lt; 0 ||
                Number(index) &gt;= Number(total)
            ) {
                return res.status(400).send("Invalid upload details");
            }

            const folder = path.join(chunksDir, id);

            await fs.promises.mkdir(folder, {
                recursive: true
            });

            await fs.promises.rename(
                req.file.path,
                path.join(folder, `${index}.part`)
            );

            res.send("Chunk received");

        } catch (error) {
            res.status(500).send("Upload failed");
        }
    }
);
</code></pre>
<p>The server stores each chunk using its upload ID and index, making it possible to identify and retrieve the individual parts when the upload is complete.</p>
<h3 id="heading-step-4-reassemble-the-original-file">Step 4: Reassemble the Original File</h3>
<p>Once the client confirms all chunks have been transmitted, it triggers a final reassembly route. The server first checks whether every expected chunk exists in the <code>chunks/</code> directory. It then reads the chunks sequentially using <code>fs.createReadStream()</code> and writes them to the same output file using <code>fs.createWriteStream()</code>. The first chunk creates the file, while subsequent chunks are appended to it. Once the original file has been reassembled, the server deletes the temporary chunks to free up space.</p>
<pre><code class="language-javascript">const uploadsDir = path.join(__dirname, "uploads");

fs.mkdirSync(uploadsDir, { recursive: true });

app.use(express.json());

app.post("/complete/:id", async (req, res) =&gt; {
    try {
        const { id } = req.params;
        const { totalChunks, filename } = req.body;

        if (
            !/^[a-f0-9-]{36}$/i.test(id) ||
            !Number.isInteger(totalChunks) ||
            totalChunks &lt; 1 ||
            totalChunks &gt; 10000 ||
            typeof filename !== "string"
        ) {
            return res.status(400).send("Invalid upload details");
        }

        const folder = path.join(chunksDir, id);

        for (let i = 0; i &lt; totalChunks; i++) {
            const chunkPath = path.join(folder, `${i}.part`);

            if (!fs.existsSync(chunkPath)) {
                return res.status(400).send(`Missing chunk ${i}`);
            }
        }

        const safeName =
            path.basename(filename).replace(/[^\w.-]/g, "_") ||
            "upload.bin";

        const outputPath = path.join(
            uploadsDir,
            `${id}-${safeName}`
        );

        await fs.promises.rm(outputPath, { force: true });

        for (let i = 0; i &lt; totalChunks; i++) {
            const chunkPath = path.join(folder, `${i}.part`);

            await pipeline(
                fs.createReadStream(chunkPath),
                fs.createWriteStream(outputPath, {
                    flags: i === 0 ? "w" : "a"
                })
            );
        }

        await fs.promises.rm(folder, {
            recursive: true,
            force: true
        });

        res.json({
            message: "File assembled successfully"
        });

    } catch (error) {
        res.status(500).send("Reassembly failed");
    }
});
</code></pre>
<p>Finally, add the following code at the bottom of <code>server.js</code> to start the Express server:</p>
<pre><code class="language-javascript">app.listen(3000, () =&gt; {
    console.log("Server running at http://localhost:3000");
});
</code></pre>
<h3 id="heading-step-5-test-the-upload">Step 5: Test the Upload</h3>
<p>Start the server by running <code>node server.js</code> in your terminal. Then, open <a href="http://localhost:3000"><code>http://localhost:3000</code></a> in your browser and select a medium-sized video file to test the upload. Open your browser's Network tab to observe how the file is divided into chunks and sent sequentially. Finally, check your server's <code>uploads/</code> directory. The assembled file should have the same byte size as the original and should open correctly.</p>
<p>Create <code>public/index.html</code>:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
    &lt;title&gt;File Uploader&lt;/title&gt;
&lt;/head&gt;
&lt;body&gt;
    &lt;h2&gt;Upload a File&lt;/h2&gt;

    &lt;input type="file" id="fileInput"&gt;
    &lt;button id="uploadButton"&gt;Upload&lt;/button&gt;

    &lt;p id="status"&gt;&lt;/p&gt;

    &lt;script src="upload.js"&gt;&lt;/script&gt;

    &lt;script&gt;
        const button = document.getElementById("uploadButton");
        const fileInput = document.getElementById("fileInput");
        const status = document.getElementById("status");

        button.addEventListener("click", async () =&gt; {
            const file = fileInput.files[0];

            if (!file) {
                status.textContent = "Select a file first.";
                return;
            }

            button.disabled = true;

            try {
                await uploadFile(file);
                status.textContent = "Upload completed!";
            } catch (error) {
                status.textContent = error.message;
            } finally {
                button.disabled = false;
            }
        });
    &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p><strong>Note:</strong> This implementation demonstrates the basic mechanics of chunked uploads and file reassembly. It doesn't implement resumable uploads, checksum verification, authentication, object storage, or temporary download links.</p>
<p>A production-grade file transfer service would also require persistent upload states, access controls, retry handling, integrity checks, and automated cleanup.</p>
<h2 id="heading-conclusion-a-simple-link-hides-a-lot-of-engineering">Conclusion: A Simple Link Hides a Lot of Engineering</h2>
<p>Sending a large file involves several processes working together. From upload handling and chunk management in the browser to object storage, metadata tracking, encryption, and automated cleanup, each component has a specific role in making file transfers work.</p>
<p>These processes also explain how services handle interrupted uploads, control access to shared files, and remove data when a transfer expires. Understanding how these components work together makes it easier to see what happens behind the scenes whenever someone uploads a large file and shares a download link.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Diagnose vite: not found After npm ci in a Container Build ]]>
                </title>
                <description>
                    <![CDATA[ I recently worked on a project where a Node.js application used Vite to compile its frontend. It worked in a browser-hosted development environment, but its image build failed after the move to Azure  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/diagnose-vite-not-found-npm-ci-container-build/</link>
                <guid isPermaLink="false">6ad8e242b2fc46237526d8b7</guid>
                
                    <category>
                        <![CDATA[ npm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ vite ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ahmed Tariq ]]>
                </dc:creator>
                <pubDate>Wed, 21 Oct 2026 16:03:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c7a9e1bf-42bd-46de-a3db-6939e863e53b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I recently worked on a project where a Node.js application used Vite to compile its frontend. It worked in a browser-hosted development environment, but its image build failed after the move to Azure Container Registry. The build reported a missing Vite executable after an earlier dependency install had failed.</p>
<p>The container build ended with a simple error:</p>
<pre><code class="language-text">sh: 1: vite: not found
</code></pre>
<p>Vite already appeared in <code>devDependencies</code>. The build stage had run <code>npm ci</code> before calling the build script. The Dockerfile looked ordinary, so the first explanations all pointed at dependency selection or <code>NODE_ENV</code>.</p>
<p>The missing binary came from an earlier failure. <code>npm ci</code> had tried to download hundreds of tarballs from a package host that only existed inside the browser-based development environment. It printed <code>Exit handler never called!</code>, left <code>node_modules</code> empty, and didn't give Docker a clear failure status. The following layer reached <code>npm run build</code> and reported the first missing executable it needed.</p>
<p>The lockfile pointed npm at an unreachable host. This tutorial shows how to check whether the install completed, find the affected tarball URLs, and check the dependency layer before compiling the application. The repair changes the public-package URL prefix and adds build checks. Package versions, integrity values, application code, and Vite configuration stay the same.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-before-you-begin">Before You Begin</a></p>
</li>
<li><p><a href="#heading-how-the-build-reached-the-failure">How the Build Reached the Failure</a></p>
</li>
<li><p><a href="#heading-step-1-check-whether-npm-ci-installed-anything">Step 1: Check Whethernpm ciInstalled Anything</a></p>
</li>
<li><p><a href="#heading-step-2-find-where-the-lockfile-would-download-packages">Step 2: Find Where the Lockfile Would Download Packages</a></p>
</li>
<li><p><a href="#heading-step-3-rewrite-only-the-known-public-package-url-prefix">Step 3: Rewrite Only the Known Public-package URL Prefix</a></p>
</li>
<li><p><a href="#heading-step-4-apply-the-guard-at-every-independent-install">Step 4: Apply the Guard at Every Independent Install</a></p>
</li>
<li><p><a href="#heading-step-5-verify-the-dependency-tree-and-the-trust-boundary">Step 5: Verify the Dependency Tree and the Trust Boundary</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-before-you-begin">Before You Begin</h2>
<p>You need a Node.js project with <code>package.json</code>, a version 2 or 3 <code>package-lock.json</code>, its container build log, and Docker. The example uses a supported Node 24 image and a Vite project that writes its build output to <code>dist</code>. Check your own dependency compatibility and output directory.</p>
<p>Check whether the affected packages come from a public or private registry before changing any URLs. The <code>.invalid</code> hostname below stands in for the original internal host. Adapt that prefix only after reviewing the packages it serves.</p>
<h2 id="heading-how-the-build-reached-the-failure">How the Build Reached the Failure</h2>
<p>The application came from a browser-hosted development environment and later moved into an Azure Container Registry build. The development platform acted as a proxy for public npm packages. When it regenerated <code>package-lock.json</code>, it stored its own internal tarball URLs in the <code>resolved</code> fields.</p>
<p>The failure crossed two environments:</p>
<ol>
<li><p>Inside the development environment, npm could reach the package proxy. The lockfile kept those proxy URLs.</p>
</li>
<li><p>The Azure Container Registry image builder received the same lockfile, but it couldn't resolve the proxy hostname.</p>
</li>
<li><p>The dependency install printed <code>Exit handler never called!</code> and left <code>node_modules</code> empty. Its failure status didn't stop the next Docker layer in this incident.</p>
</li>
<li><p>The next layer ran <code>npm run build</code>, which called Vite. With no installed Vite executable, the build reported <code>vite: not found</code>.</p>
</li>
</ol>
<p>The checks below follow that chain backward from the missing executable to the package locations, then repair the URLs and guard each independent install.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c08efcc4195d32e16a4e7a/a78d37d6-1fb7-4b28-b950-5d24f3b90d8d.png" alt="A lockfile points npm at an unreachable package host. npm leaves node_modules empty, and the next Docker layer reports that Vite is missing." style="display: block;" width="1613" height="359" loading="lazy">

<p><em>Figure 1. The container build reported the last failure in the chain.</em></p>
<h2 id="heading-step-1-check-whether-npm-ci-installed-anything">Step 1: Check Whether <code>npm ci</code> Installed Anything</h2>
<p>The project used <code>npm ci</code>, which was the correct command for a repeatable container build. npm's documentation says that the command removes an existing <code>node_modules</code> directory before it starts the install. It also treats the lockfile as frozen instead of updating it.</p>
<p>Those properties made this failure unusually clear. The build couldn't fall back to packages from an earlier local install. Once the tarball downloads failed, the dependency directory contained nothing useful.</p>
<p>The Dockerfile separated dependency installation from source compilation. Step 3 compares that original sequence with the revised build.</p>
<p>The later <code>vite: not found</code> message didn't show whether npm had installed the package. Start with the dependency directory in the same environment that failed:</p>
<pre><code class="language-bash">test -d node_modules &amp;&amp; find node_modules -mindepth 1 -maxdepth 1 | head
test -x node_modules/.bin/vite
</code></pre>
<p>In this build, the first check found no installed packages. The second confirmed that the Vite executable was missing. A missing binary can also result from omitting development dependencies, so inspect the directory before changing <code>NODE_ENV</code> or the package manifest.</p>
<p>After a successful install, the directory listing should show package names. The <code>test -x</code> command prints nothing. Immediately after it, inspect its exit status:</p>
<pre><code class="language-bash">echo $?
</code></pre>
<p>For a missing Vite executable, the expected result is:</p>
<pre><code class="language-text">1
</code></pre>
<p>A result of <code>0</code> means the executable exists and is executable.</p>
<p><strong>Checkpoint:</strong> An empty dependency directory points to the install step. If other packages exist but Vite is missing, also check whether the install omitted development dependencies. These commands inspect the dependency directory without changing it.</p>
<h2 id="heading-step-2-find-where-the-lockfile-would-download-packages">Step 2: Find Where the Lockfile Would Download Packages</h2>
<p>A generalized entry looked like this:</p>
<pre><code class="language-json">{
  "node_modules/example-package": {
    "version": "1.2.3",
    "resolved": "http://packages.internal.invalid/npm/example-package/-/example-package-1.2.3.tgz",
    "integrity": "sha512-&lt;subresource-integrity-value&gt;"
  }
}
</code></pre>
<p>The <code>.invalid</code> hostname above replaces the real internal hostname. The structure matches the historical lockfile.</p>
<p>npm documents <code>resolved</code> as the location from which it obtained a package. For a registry package, that value can be a tarball URL. The configured registry on the build runner therefore didn't solve this incident. The lockfile already told npm where to fetch each tarball.</p>
<p>I counted the affected entries in the historical file. Of 1,038 dependency entries, 528 carried the internal hostname. Each pointed at a host that didn't resolve in the external build environment.</p>
<p>The npm debug output pointed to an earlier failure. The hostname failures appeared before <code>Exit handler never called!</code>. That order mattered. The exit-handler message described how npm ended, while the failed downloads explained why the dependency tree was empty.</p>
<p>I inspected the lockfile as data instead of searching for one package name. This small script groups every HTTP <code>resolved</code> value by hostname:</p>
<pre><code class="language-javascript">const fs = require('node:fs')
const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8'))
const hosts = {}

for (const entry of Object.values(lock.packages ?? {})) {
  if (!entry.resolved?.startsWith('http')) continue
  const host = new URL(entry.resolved).host
  hosts[host] = (hosts[host] ?? 0) + 1
}

console.table(hosts)
</code></pre>
<p>That inventory showed that the problem affected 528 entries rather than one broken tarball. It also made the repair boundary clear. The package locations recorded in <code>resolved</code> needed to change, while versions and integrity values stayed the same. If the host list includes a private registry, stop here and identify which packages it serves before rewriting anything.</p>
<p>The historical inventory contained this row. The table uses the replacement hostname from the example:</p>
<table>
<thead>
<tr>
<th>Host</th>
<th>Resolved URLs</th>
</tr>
</thead>
<tbody><tr>
<td><code>packages.internal.invalid</code></td>
<td>528</td>
</tr>
</tbody></table>
<p>Your hostnames and counts will depend on your lockfile. This inventory counts HTTP tarball locations, so its total can differ from the number of dependency entries.</p>
<p><strong>Checkpoint:</strong> Identify the unreachable host and confirm which of its packages come from the public npm registry. Record the affected count before choosing a replacement prefix. A hostname count alone doesn't establish that rewriting those URLs is safe.</p>
<h2 id="heading-step-3-rewrite-only-the-known-public-package-url-prefix">Step 3: Rewrite Only the Known Public-package URL Prefix</h2>
<p>Regenerating the lockfile can mix a registry repair with dependency updates. Keeping the package versions and integrity values unchanged makes the repair easier to review.</p>
<p>The affected packages in this application came from the public npm registry. The development platform proxied them and preserved their integrity values. The historical repair replaced its URL prefix before the container install.</p>
<p>For this walkthrough, save the following as <code>rewrite-lockfile.cjs</code>. It changes only <code>resolved</code> fields with the approved prefix. It checks for missing integrity values and unexpected paths on the old host before writing the file.</p>
<pre><code class="language-javascript">const fs = require('node:fs')

const oldPrefix = 'http://packages.internal.invalid/npm/'
const newPrefix = 'https://registry.npmjs.org/'
const oldHostname = new URL(oldPrefix).hostname
const filename = 'package-lock.json'
const lock = JSON.parse(fs.readFileSync(filename, 'utf8'))

if (!lock.packages || typeof lock.packages !== 'object') {
  throw new Error('Expected a lockfile with a packages object (version 2 or 3)')
}

let rewritten = 0
for (const entry of Object.values(lock.packages)) {
  if (typeof entry.resolved !== 'string') continue
  if (!entry.resolved.startsWith(oldPrefix)) continue
  if (!entry.integrity) throw new Error('An affected package has no integrity value')
  entry.resolved = newPrefix + entry.resolved.slice(oldPrefix.length)
  rewritten += 1
}

for (const entry of Object.values(lock.packages)) {
  if (typeof entry.resolved !== 'string') continue
  if (!entry.resolved.startsWith('http')) continue
  if (new URL(entry.resolved).hostname === oldHostname) {
    throw new Error('The old hostname remains under an unexpected URL prefix')
  }
}

fs.writeFileSync(filename, JSON.stringify(lock, null, 2) + '\n')
console.log(`Rewrote ${rewritten} resolved URLs`)
</code></pre>
<p>When the dependency stage runs the script, its message should match the number of URLs that use the approved prefix. The small test project used to check this tutorial produced:</p>
<pre><code class="language-text">Rewrote 40 resolved URLs
</code></pre>
<p>That test project is separate from the historical application, which had 528 affected entries. Use the inventory for your own file as the expected count. A zero count on an already-repaired file is expected. If an unrepaired file produces zero, check the prefix.</p>
<p>The script doesn't establish that a package is public. Confirm that separately. npm's integrity check then verifies that the downloaded bytes match the lockfile.</p>
<p>Keep local dependencies and configuration files out of the build context with a <code>.dockerignore</code>:</p>
<pre><code class="language-text">node_modules
dist
.git
.env*
.npmrc
</code></pre>
<p>This example installs public packages. A private registry needs its own reachable endpoint and secure build-time authentication.</p>
<p>The first block below shows the affected instructions from the original Dockerfile. The second is the complete tutorial build. It uses Node 24 and the guarded install, so check compatibility with your own application.</p>
<h3 id="heading-before-original-install-and-build-sequence">Before: Original Install and Build Sequence</h3>
<pre><code class="language-dockerfile">COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build
</code></pre>
<h3 id="heading-after-url-rewrite-and-dependency-checks">After: URL Rewrite and Dependency Checks</h3>
<p>Use two build stages so source changes don't invalidate the dependency-install stage:</p>
<pre><code class="language-dockerfile">FROM node:24-slim AS dependencies
WORKDIR /app

COPY package.json package-lock.json rewrite-lockfile.cjs ./
RUN node rewrite-lockfile.cjs \
 &amp;&amp; npm ci --include=dev \
 &amp;&amp; test -x node_modules/.bin/vite

FROM node:24-slim AS build
WORKDIR /app

COPY . .
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=dependencies /app/package-lock.json ./package-lock.json
RUN npm run build
</code></pre>
<p>The revised Dockerfile makes each part of the install explicit:</p>
<ul>
<li><p>The URL rewrite replaces the approved public-package prefix before <code>npm ci</code> downloads packages.</p>
</li>
<li><p><code>--include=dev</code> makes the choice to install development dependencies explicit.</p>
</li>
<li><p>The Vite executable check stops the dependency stage if the install leaves Vite missing.</p>
</li>
<li><p>The separate dependency stage installs packages before the application build stage copies them and compiles the source.</p>
</li>
</ul>
<p>The second stage copies the repaired lockfile after the application source. Otherwise, <code>COPY . .</code> would put the original lockfile back into the build image. The <code>.dockerignore</code> also prevents local <code>node_modules</code> from overwriting the dependencies from the first stage.</p>
<p><code>--include=dev</code> includes Vite when it's a development dependency, even if the build environment sets <code>NODE_ENV=production</code>. This image is a build environment, so it needs the compiler and build tools. Choose a supported Node version compatible with your project.</p>
<p>The historical lockfile comparison found 528 changed fields, all named <code>resolved</code>. It found zero changed <code>version</code> or <code>integrity</code> fields and no remaining internal hostname. The example script above also leaves unrelated fields unchanged, although JSON formatting can change.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61c08efcc4195d32e16a4e7a/16ca3441-7987-43cd-af39-d75ac51dbf00.png" alt="The rewrite changes only each affected resolved URL. Package versions and integrity values stay the same." style="display: block;" width="904" height="985" loading="lazy">

<p><em>Figure 2. The portability repair changes package locations while preserving dependency identity.</em></p>
<p>The executable check makes the dependency stage fail if Vite is still missing. Docker stops at that stage instead of reaching the application build.</p>
<p><strong>Checkpoint:</strong> Check the rewrite count against your inventory, confirm that the approved old hostname is gone, and compare versions and integrity values before and after the repair. The dependency stage must complete both <code>npm ci --include=dev</code> and the Vite executable check. Step 5 builds the image and verifies the application output.</p>
<h2 id="heading-step-4-apply-the-guard-at-every-independent-install">Step 4: Apply the Guard at Every Independent Install</h2>
<p>The continuous integration job and the container image builder ran separate installs. Fixing the workflow runner didn't change the <code>npm ci</code> inside the Dockerfile. The image build received the original repository files and created its own dependency layer in another environment.</p>
<p>The rewrite belongs immediately after the dependency files enter the install environment. <a href="https://docs.docker.com/build/cache/optimize/">Docker's cache guidance</a> recommends installing dependencies before copying frequently changing source files. In the example, the dependency stage has that boundary. A source-only change rebuilds the application stage.</p>
<p>Apply the same guard before each independent install:</p>
<ul>
<li><p>the continuous integration type-check install</p>
</li>
<li><p>the container build install</p>
</li>
<li><p>any runtime-stage install in a multi-stage Dockerfile</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/61c08efcc4195d32e16a4e7a/5bb8c97d-e76e-4da9-b7c4-efd51a248eca.png" alt="The repository lockfile enters the CI runner and container builder separately. Each environment rewrites and checks the URLs before running npm ci." style="display: block;" width="1032" height="712" loading="lazy">

<p><em>Figure 3. A workflow repair doesn't automatically repair the install inside a container build.</em></p>
<p>This placement also prevented a misleading partial fix. A type check could otherwise pass while the image build failed because the two jobs used the same lockfile in separate installs.</p>
<h2 id="heading-step-5-verify-the-dependency-tree-and-the-trust-boundary">Step 5: Verify the Dependency Tree and the Trust Boundary</h2>
<p>This repair was safe because the affected entries represented public npm packages behind a transparent development-platform proxy. The integrity strings remained present, and the rewrite didn't change any package version.</p>
<p>The same substitution would be unsafe for private packages, patched packages, or a registry that serves different bytes under the same package version. A portable build should keep those packages on a reachable private registry and configure authentication for that registry.</p>
<p>Before applying this pattern elsewhere, I would inventory every <code>resolved</code> hostname and separate public packages from private ones. I would then compare package versions and integrity values before and after the change. The build should fail if an unapproved hostname remains.</p>
<p>An npm CLI issue records the same <code>Exit handler never called!</code> message after package downloads from an internal registry timed out. That message doesn't identify the package location by itself. In this incident, the tarball URLs and failed hostname established the cause.</p>
<p>Build the image and verify the output:</p>
<pre><code class="language-bash">docker build --progress=plain -t vite-install-check .
docker run --rm vite-install-check sh -c \
  'test -x node_modules/.bin/vite &amp;&amp; test -s dist/index.html'
</code></pre>
<p>The build must pass <code>npm ci</code>, the Vite executable check, and <code>npm run build</code>. The final command checks the expected Vite output in the resulting image. Adapt the output check if your project uses a different directory. The image in this example compiles the application. Choose a separate serving stage if you need a production web server.</p>
<p>A successful minimal example doesn't validate every dependency in an existing application. Run the same build against the project you intend to ship. When a tool is missing, check the install result and its earlier errors before changing the build tool.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json/">npm documentation: package-lock.json fields</a></p>
</li>
<li><p><a href="https://docs.npmjs.com/cli/v11/commands/npm-ci/">npm documentation: npm ci</a></p>
</li>
<li><p><a href="https://github.com/npm/cli/issues/9739">npm CLI issue: Exit handler never called after registry timeouts</a></p>
</li>
<li><p><a href="https://docs.docker.com/build/cache/optimize/">Docker documentation: Optimizing build cache</a></p>
</li>
<li><p><a href="https://nodejs.org/en/about/previous-releases">Node.js release status</a></p>
</li>
<li><p><a href="https://diagrams.mingrammer.com/">Diagrams library used to create the figures</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Submit a Quarterly Update to HMRC's Making Tax Digital API ]]>
                </title>
                <description>
                    <![CDATA[ Four times a year, every sole trader and landlord in Making Tax Digital (MTD) for Income Tax has to send HMRC a summary of their income and expenses. The first deadline of the 2026-27 tax year, 7 Augu ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-submit-a-quarterly-update-to-hmrc-making-tax-digital-api/</link>
                <guid isPermaLink="false">6ac242855423a0b19b9f5ed8</guid>
                
                    <category>
                        <![CDATA[ APIs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Solomon Amos ]]>
                </dc:creator>
                <pubDate>Sun, 04 Oct 2026 12:11:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ff99ebcb-6096-4847-93a0-a18a9687bdc6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Four times a year, every sole trader and landlord in Making Tax Digital (MTD) for Income Tax has to send HMRC a summary of their income and expenses.</p>
<p>The first deadline of the 2026-27 tax year, 7 August, has already passed, and the second lands on 7 November. Each of those summaries is a single API call from software to HMRC, and getting that call right is what this tutorial is about.</p>
<p>You can follow it without any earlier reading. The one-time setup every MTD integration needs (a sandbox application, an OAuth 2.0 access token, and fraud prevention headers) is summarised in the quick recap below, together with the small request helper every snippet uses.</p>
<p>If you want that setup in depth, I walked through it step by step in a <a href="https://www.freecodecamp.org/news/how-to-connect-to-hmrc-making-tax-digital-api/">previous freeCodeCamp tutorial</a>.</p>
<p>By the end, you'll know how to find the business you're filing for, work out which period is due, build a cumulative summary HMRC accepts, submit it, and read the tax calculation that follows. The code is Node and TypeScript, trimmed from the HMRC integration I built for a Making Tax Digital app.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-quick-recap-the-setup-this-tutorial-assumes">Quick Recap: The Setup This Tutorial Assumes</a></p>
</li>
<li><p><a href="#heading-how-quarterly-updates-work-in-mtd">How Quarterly Updates Work in MTD</a></p>
</li>
<li><p><a href="#heading-step-1-how-to-find-the-business-id">Step 1: How to Find the Business ID</a></p>
</li>
<li><p><a href="#heading-step-2-how-to-find-out-whats-due">Step 2: How to Find Out What's Due</a></p>
</li>
<li><p><a href="#heading-step-3-how-to-build-the-cumulative-summary">Step 3: How to Build the Cumulative Summary</a></p>
</li>
<li><p><a href="#heading-step-4-how-to-submit-the-update">Step 4: How to Submit the Update</a></p>
</li>
<li><p><a href="#heading-step-5-how-to-trigger-and-read-a-tax-calculation">Step 5: How to Trigger and Read a Tax Calculation</a></p>
</li>
<li><p><a href="#heading-common-gotchas">Common Gotchas</a></p>
</li>
<li><p><a href="#heading-where-to-go-next">Where to Go Next</a></p>
</li>
</ul>
<h2 id="heading-quick-recap-the-setup-this-tutorial-assumes">Quick Recap: The Setup This Tutorial Assumes</h2>
<p>Before you can file anything, your application needs the same one-time setup as every MTD integration. If you already have it, skip to the next section. Otherwise, here is the short version:</p>
<ol>
<li><p><strong>Register a sandbox application</strong> on the <a href="https://developer.service.hmrc.gov.uk/">HMRC Developer Hub</a> and subscribe it to the four APIs used here: Business Details, Obligations, Self Employment Business, and Individual Calculations. An API you haven't subscribed to returns <code>403 Forbidden</code>, which looks like an authorisation problem but is not.</p>
</li>
<li><p><strong>Create a sandbox test user</strong> with HMRC's <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/api-platform-test-user/1.0">Create Test User API</a>. It gives you a fake taxpayer with a National Insurance number (NINO) and Government Gateway credentials.</p>
</li>
<li><p><strong>Get an access token</strong> through HMRC's <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/authorisation">OAuth 2.0 authorization code flow</a>, asking for the <code>read:self-assessment</code> and <code>write:self-assessment</code> scopes and signing in as your test user on HMRC's consent screen.</p>
</li>
<li><p><strong>Send fraud prevention headers</strong> on every call. HMRC <a href="https://developer.service.hmrc.gov.uk/guides/fraud-prevention/">makes them mandatory</a> and the exact set depends on how your application connects, so build them once in a <code>getFraudHeaders(req)</code> function.</p>
</li>
</ol>
<p>Every snippet in this tutorial calls one small helper. It sets the bearer token, pins the API version through the <code>Accept</code> header (each HMRC API has its own version, and the wrong one returns <code>406 Not Acceptable</code>), and attaches your fraud prevention headers:</p>
<pre><code class="language-typescript">import axios from 'axios';

const HMRC_BASE_URL = 'https://test-api.service.hmrc.gov.uk'; // sandbox

async function request(method, path, accessToken, req, data = null, apiVersion = '2.0') {
  const headers = {
    Authorization: 'Bearer ' + accessToken,
    Accept: 'application/vnd.hmrc.' + apiVersion + '+json',
    ...getFraudHeaders(req),
  };
  // Only set a JSON Content-Type when there is a body. HMRC's edge rejects
  // a bodyless GET that carries one with a 403.
  if (data !== null &amp;&amp; data !== undefined) {
    headers['Content-Type'] = 'application/json';
  }
  const res = await axios({ baseURL: HMRC_BASE_URL, method, url: path, headers, data });
  return res.data;
}
</code></pre>
<p>The <code>req</code> argument is your user's incoming request (an Express <code>Request</code> in my code), which is where the client details in the fraud prevention headers come from. With a token and this helper in place, you're ready to file.</p>
<h2 id="heading-how-quarterly-updates-work-in-mtd">How Quarterly Updates Work in MTD</h2>
<p>A quarterly update isn't a tax return. It's a set of running totals: how much the business has earned and spent so far this tax year, grouped into the same categories Self Assessment already uses.</p>
<p>The word that matters is "so far". Each update is cumulative. It covers everything from the start of the tax year to the end of the current update period, not just the last three months. <a href="https://www.gov.uk/guidance/use-making-tax-digital-for-income-tax/send-quarterly-updates">GOV.UK's guidance on sending quarterly updates</a> sets out the standard periods and their deadlines:</p>
<table>
<thead>
<tr>
<th>Update period</th>
<th>Deadline</th>
</tr>
</thead>
<tbody><tr>
<td>6 April to 5 July</td>
<td>7 August</td>
</tr>
<tr>
<td>6 April to 5 October</td>
<td>7 November</td>
</tr>
<tr>
<td>6 April to 5 January</td>
<td>7 February</td>
</tr>
<tr>
<td>6 April to 5 April</td>
<td>7 May (the following tax year)</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/5ff319e8638f6a0ef52a6236/99a6968f-6e49-4056-b589-dc73b899b946.png" alt="Timeline of the 2026-27 tax year showing four quarterly update periods. All four start on 6 April 2026. They end on 5 July, 5 October, 5 January and 5 April, with deadlines of 7 August, 7 November, 7 February and 7 May." style="display: block;" width="1600" height="760" loading="lazy">

<p>That design has a pleasant side effect: if a user spots a mistake in an earlier quarter, the next update simply carries the correction. <a href="https://developer.service.hmrc.gov.uk/guides/income-tax-mtd-end-to-end-service-guide/documentation/make-updates-during-tax-year.html">HMRC's end-to-end service guide</a> puts it plainly: each update invalidates the previous one, because every period starts on 6 April.</p>
<p>Customers whose accounting year runs from 1 April can opt into calendar periods instead (1 April to 30 June, and so on), with the same deadlines. You don't need to hardcode either set, because the Obligations API returns the exact dates.</p>
<p>HMRC calls each required update an "obligation": four per tax year for each self-employment or property business, plus the annual tax return.</p>
<p>Here is the whole journey you're about to build:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ff319e8638f6a0ef52a6236/4be590f1-3f8e-40f8-8b02-40de4325df7a.png" alt="Sequence diagram of a quarterly update between your application and the HMRC API. 1: list businesses to get the businessId. 2: get open obligations to get the period dates and due date. 3: PUT the cumulative summary, which returns 204. 4: trigger an in-year calculation, which returns 202 and a calculationId. 5: wait at least five seconds, then retrieve the calculation, retrying on 404 until it returns 200." style="display: block;" width="1600" height="1000" loading="lazy">

<h2 id="heading-step-1-how-to-find-the-business-id">Step 1: How to Find the Business ID</h2>
<p>Every self-employment endpoint needs a <code>businessId</code>, HMRC's identifier for one income source. A sole trader who also lets a property has two. You get them from the <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/business-details-api/2.0">Business Details API</a> with the customer's National Insurance number (NINO):</p>
<pre><code class="language-typescript">// GET /individuals/business/details/{nino}/list  (Business Details API v2.0)
const result = await request(
  'GET',
  '/individuals/business/details/' + nino + '/list',
  accessToken,
  req,
  null,
  '2.0',
);

const businesses = result.listOfBusinesses ?? [];
const soleTrade = businesses.find((b) =&gt; b.typeOfBusiness === 'self-employment');
const businessId = soleTrade?.businessId;
</code></pre>
<p>The array lives under <code>listOfBusinesses</code>, and each entry carries a <code>typeOfBusiness</code> (<code>self-employment</code>, <code>uk-property</code>, <code>foreign-property</code>, or <code>property-unspecified</code>), the <code>businessId</code>, and optionally a <code>tradingName</code>. Sandbox self-employment IDs look like <code>XBIS12345678901</code>.</p>
<p>HMRC's guide recommends storing the ID rather than looking it up before every call, and I do. The list changes rarely, typically when a customer adds or ceases a business, so refresh it when they connect and when they ask.</p>
<h2 id="heading-step-2-how-to-find-out-whats-due">Step 2: How to Find Out What's Due</h2>
<p>Next, ask the <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/obligations-api/3.0">Obligations API</a> which periods are still open. This endpoint is on version 3.0:</p>
<pre><code class="language-typescript">// GET /obligations/details/{nino}/income-and-expenditure  (Obligations API v3.0)
const raw = await request(
  'GET',
  '/obligations/details/' + nino + '/income-and-expenditure?status=open',
  accessToken,
  req,
  null,
  '3.0',
);
</code></pre>
<p>The response groups obligations by business, with the dates nested one level down:</p>
<pre><code class="language-json">{
  "obligations": [
    {
      "typeOfBusiness": "self-employment",
      "businessId": "XBIS12345678901",
      "obligationDetails": [
        {
          "periodStartDate": "2026-04-06",
          "periodEndDate": "2026-10-05",
          "dueDate": "2026-11-07",
          "status": "open"
        }
      ]
    }
  ]
}
</code></pre>
<p>That nesting gets awkward quickly in a UI, so I flatten it into one row per obligation, lift the business fields onto each row, and pick the open one with the earliest due date:</p>
<pre><code class="language-typescript">function flattenObligations(raw, businessId) {
  return (raw.obligations ?? [])
    .filter((group) =&gt; group.businessId === businessId)
    .flatMap((group) =&gt;
      (group.obligationDetails ?? []).map((d) =&gt; ({
        businessId: group.businessId,
        periodStartDate: d.periodStartDate,
        periodEndDate: d.periodEndDate,
        dueDate: d.dueDate,
        status: (d.status ?? '').toLowerCase() === 'fulfilled' ? 'fulfilled' : 'open',
      })),
    );
}

const next = flattenObligations(raw, businessId)
  .filter((o) =&gt; o.status === 'open')
  .sort((a, b) =&gt; a.dueDate.localeCompare(b.dueDate))[0];
</code></pre>
<p>Three details are worth knowing before you build a screen on this.</p>
<p>First, these obligations have no period key. The start and end dates identify a period, and they're exactly what you send back in step 3. If you need a stable key, derive one from the two dates.</p>
<p>Second, read <code>dueDate</code> from the response instead of calculating it. HMRC sets these dates and has changed them before, so let the API be the source of truth rather than a date rule in your code.</p>
<p>Third, the <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/obligations-api/3.0/oas/page">filters have rules</a>. <code>fromDate</code> and <code>toDate</code> must be sent together and at most 366 days apart, and a <code>businessId</code> filter also requires <code>typeOfBusiness</code>. Fetching everything open and filtering in your own code, as above, sidesteps both.</p>
<h2 id="heading-step-3-how-to-build-the-cumulative-summary">Step 3: How to Build the Cumulative Summary</h2>
<p>Now for the payload. The <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/self-employment-business-api/5.0">Self Employment Business API</a> calls it a cumulative period summary, and it has four parts:</p>
<ul>
<li><p><code>periodDates</code>: required. The start and end of the period you're reporting, copied from the obligation.</p>
</li>
<li><p><code>periodIncome</code>: <code>turnover</code> (takings, fees, and sales), <code>other</code> business income, and <code>taxTakenOffTradingIncome</code>.</p>
</li>
<li><p><code>periodExpenses</code>: either one <code>consolidatedExpenses</code> figure or an itemised breakdown.</p>
</li>
<li><p><code>periodDisallowableExpenses</code>: the part of each itemised expense that can't be claimed for tax.</p>
</li>
</ul>
<p>Here's a complete, valid body for the second quarter of 2026-27, using a single consolidated expenses figure:</p>
<pre><code class="language-json">{
  "periodDates": {
    "periodStartDate": "2026-04-06",
    "periodEndDate": "2026-10-05"
  },
  "periodIncome": {
    "turnover": 28450,
    "other": 0
  },
  "periodExpenses": {
    "consolidatedExpenses": 4310.45
  }
}
</code></pre>
<p>The itemised form swaps that figure for categories that mirror the Self Assessment boxes, such as <code>costOfGoods</code>, <code>carVanTravelExpenses</code>, <code>adminCosts</code>, and <code>professionalFees</code>. If part of an expense was personal, the disallowable portion goes in the matching field:</p>
<pre><code class="language-json">"periodExpenses": {
  "costOfGoods": 2100,
  "carVanTravelExpenses": 1640.2,
  "adminCosts": 185.99
},
"periodDisallowableExpenses": {
  "carVanTravelExpensesDisallowable": 410.05
}
</code></pre>
<p>You can't mix the two forms. Sending <code>consolidatedExpenses</code> alongside any itemised field returns <code>RULE_BOTH_EXPENSES_SUPPLIED</code>. And because each disallowable field pairs with an itemised category, disallowable expenses belong with the itemised form.</p>
<p>So when is the consolidated form allowed? <a href="https://developer.service.hmrc.gov.uk/guides/income-tax-mtd-end-to-end-service-guide/documentation/make-updates-during-tax-year.html">HMRC's service guide</a> says customers with annual turnover under £90,000 can report a single expenses total. Because every update is cumulative, the practical check is to compare year-to-date turnover with the threshold on each submission. Once it reaches £90,000, the update has to be itemised. I enforce that on the server before anything goes to HMRC:</p>
<pre><code class="language-typescript">const CONSOLIDATED_EXPENSES_TURNOVER_LIMIT = 90_000;

function checkConsolidatedExpensesLimit({ turnover, usesConsolidatedExpenses }) {
  if (!usesConsolidatedExpenses) return null;
  if ((turnover ?? 0) &gt;= CONSOLIDATED_EXPENSES_TURNOVER_LIMIT) {
    return (
      'Consolidated expenses are not permitted once turnover reaches £90,000. ' +
      'Please enter your expenses itemised instead.'
    );
  }
  return null;
}
</code></pre>
<p>Finally, every amount allows at most two decimal places, so round your totals (I use <code>Number(total.toFixed(2))</code>) before they leave your code.</p>
<h2 id="heading-step-4-how-to-submit-the-update">Step 4: How to Submit the Update</h2>
<p>With the body built, the submission itself is one <code>PUT</code>. The tax year goes in the path in <code>YYYY-YY</code> form, and the period dates travel in the body, not the URL:</p>
<pre><code class="language-typescript">// PUT /individuals/business/self-employment/{nino}/{businessId}/cumulative/{taxYear}
// Self Employment Business API v5.0
await request(
  'PUT',
  '/individuals/business/self-employment/' + nino + '/' + businessId + '/cumulative/' + taxYear,
  accessToken,
  req,
  summary,
  '5.0',
);
</code></pre>
<p>Success is <code>204 No Content</code>, with no receipt number, so keep your own audit record of what you sent, when, and the status HMRC returned. You can read HMRC's copy back at any time with a <code>GET</code> on the same path.</p>
<p>Because it's a <code>PUT</code>, the same call creates and amends. Resubmitting for the same tax year replaces what HMRC holds. That's the cumulative model working as designed, and also the source of the most expensive bug in this flow (see the gotchas).</p>
<p>This endpoint only accepts tax years from 2025-26 onwards. Older tutorials that <code>POST</code> to a <code>/period</code> endpoint describe the earlier, per-period model.</p>
<h2 id="heading-step-5-how-to-trigger-and-read-a-tax-calculation">Step 5: How to Trigger and Read a Tax Calculation</h2>
<p>After an update, customers want to know roughly how much tax they're heading for. The <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/individual-calculations-api/8.0">Individual Calculations API</a> answers that asynchronously: you trigger a calculation, get back an ID, and fetch the result a little later. The calculation type goes at the end of the path, and <code>in-year</code> is the one you want after a quarterly update:</p>
<pre><code class="language-typescript">// POST /individuals/calculations/{nino}/self-assessment/{taxYear}/trigger/in-year
// Individual Calculations API v8.0. Returns 202 Accepted with a calculationId.
const { calculationId } = await request(
  'POST',
  '/individuals/calculations/' + nino + '/self-assessment/' + taxYear + '/trigger/in-year',
  accessToken,
  req,
  {}, // an empty object, not null (see the gotchas)
  '8.0',
);
</code></pre>
<p><a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/individual-calculations-api/8.0/oas/page">HMRC's endpoint documentation</a> recommends waiting at least five seconds before you try to retrieve the result. Until the calculation is ready, the retrieve endpoint returns <code>404 Not Found</code>, so a short, bounded retry loop handles it:</p>
<pre><code class="language-typescript">const sleep = (ms) =&gt; new Promise((resolve) =&gt; setTimeout(resolve, ms));

async function waitForCalculation(nino, taxYear, calculationId, accessToken, req) {
  const path =
    '/individuals/calculations/' + nino + '/self-assessment/' + taxYear + '/' + calculationId;
  await sleep(5000); // HMRC recommends waiting at least five seconds after the trigger

  for (let attempt = 0; attempt &lt; 5; attempt += 1) {
    try {
      return await request('GET', path, accessToken, req, null, '8.0');
    } catch (err) {
      // Only a 404 means "not ready yet". Anything else is a real error, so rethrow it.
      if (err.response?.status !== 404 || attempt === 4) throw err;
    }
    await sleep(1000 * (attempt + 1));
  }
}
</code></pre>
<p>The full result covers every kind of income HMRC knows about, but a quarterly update screen needs only a few fields. Here's an abridged response with illustrative figures:</p>
<pre><code class="language-json">{
  "metadata": {
    "calculationId": "f2fb30e5-4ab6-4a29-b3c1-c7264259ff1c",
    "taxYear": "2026-27",
    "calculationType": "in-year",
    "periodFrom": "2026-04-06",
    "periodTo": "2026-10-05"
  },
  "calculation": {
    "taxCalculation": {
      "totalIncomeTaxAndNicsDue": 3902.6
    }
  }
}
</code></pre>
<p><code>calculation.taxCalculation.totalIncomeTaxAndNicsDue</code> is the headline number. <code>metadata.periodTo</code> shows how far into the year the figures run: the end of the latest submission, not the date you asked.</p>
<p>If the submitted data fails HMRC's checks, there's no calculation at all. Instead, <code>messages.errors</code> holds a list of <code>{ id, text }</code> objects, and you should show that text to the customer so they can fix their records.</p>
<p>Before you show any in-year figure, HMRC's minimum functionality standards require a disclaimer. The <a href="https://developer.service.hmrc.gov.uk/guides/income-tax-mtd-end-to-end-service-guide/documentation/tax-calculations.html">tax calculations section of HMRC's service guide</a> offers this wording, or you can write your own:</p>
<blockquote>
<p>"This calculation is only based on information HMRC have received about your income and expenses to 20XX-XX-XX. This may change as we receive further information about you during the tax year."</p>
</blockquote>
<p>Fill the date from <code>periodTo</code>. It also helps to say that this is an estimate: HMRC's guide is explicit that the customer doesn't need to pay anything at this point.</p>
<h2 id="heading-common-gotchas">Common Gotchas</h2>
<h3 id="heading-1-the-default-sandbox-data-is-years-out-of-date">1. The Default Sandbox Data Is Years Out of Date</h3>
<p>Call the Obligations API in the sandbox with no test scenario and you get static data from an old tax year, which the cumulative endpoint rejects with <code>RULE_TAX_YEAR_NOT_SUPPORTED</code>. Send <code>Gov-Test-Scenario: DYNAMIC</code> instead to get open obligations for the current year.</p>
<p>The cumulative endpoint isn't stateful by default either: a <code>GET</code> after your <code>PUT</code> returns canned figures. For a true round trip, use the <code>STATEFUL</code> scenario with a business created through the <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/mtd-sa-test-support-api/1.0">Self Assessment Test Support API</a>.</p>
<h3 id="heading-2-a-new-update-replaces-the-old-one">2. A New Update Replaces the Old One</h3>
<p>Since each <code>PUT</code> replaces the year's figures, a form that opens blank and submits only what the user typed can wipe out earlier quarters. Prefill it from the retrieve endpoint or your own records, so every resubmission starts from the full year-to-date totals.</p>
<h3 id="heading-3-send-zeros-dont-leave-fields-out">3. Send Zeros, Don't Leave Fields Out</h3>
<p><a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/self-employment-business-api/5.0/oas/page">HMRC's endpoint documentation</a> says submissions must include income and expense values even when they're zero, so a quarter with no income still sends <code>turnover</code> and <code>other</code> as <code>0</code>. <a href="https://www.gov.uk/guidance/use-making-tax-digital-for-income-tax/send-quarterly-updates">GOV.UK</a> also says customers must send an update when nothing happened in the period, so don't block a submission because every figure is zero.</p>
<h3 id="heading-4-you-cant-file-too-early">4. You Can't File Too Early</h3>
<p>The <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/self-employment-business-api/5.0/oas/page">cumulative endpoint</a> rejects a submission made more than 10 days before the period ends (<code>RULE_EARLY_DATA_SUBMISSION_NOT_ACCEPTED</code>), and an end date earlier than one already submitted (<code>RULE_SUBMISSION_END_DATE_CANNOT_MOVE_BACKWARDS</code>). Show the period end date clearly and enable the submit button only when it applies.</p>
<h3 id="heading-5-fulfilled-can-take-an-hour">5. "Fulfilled" Can Take an Hour</h3>
<p>After a successful <code>204</code>, <a href="https://developer.service.hmrc.gov.uk/guides/income-tax-mtd-end-to-end-service-guide/documentation/make-updates-during-tax-year.html">HMRC can take up to an hour</a> to mark the obligation as fulfilled, so an immediate re-read still shows it as open. Trust your own record of the submission and tell the customer the status will update shortly.</p>
<h3 id="heading-6-trigger-the-calculation-with-an-empty-object">6. Trigger the Calculation With an Empty Object</h3>
<p>In my testing, posting the trigger with a <code>null</code> body produced a <code>500</code> from HMRC's calculation backend, while an empty JSON object (<code>{}</code>) was accepted. Watch the version too: <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/individual-calculations-api/8.0">Individual Calculations</a> 9.0 is in the sandbox, but 8.0 is the version available in production at the time of writing.</p>
<h2 id="heading-where-to-go-next">Where to Go Next</h2>
<p>You now have the complete quarterly loop: find the business, read its open obligations, build a cumulative summary that respects the consolidated-expenses rule, submit it, then trigger, wait for, and read the calculation with the right disclaimer.</p>
<p>After the fourth update, the year ends in the same Individual Calculations API: you trigger an <code>intent-to-finalise</code> calculation instead of <code>in-year</code>, show the customer the result, and submit their final declaration against that calculation ID.</p>
<p>That deserves a tutorial of its own, and it's the one I plan to write next.</p>
<p>One practical note before you plan a launch: HMRC's API pages currently carry a notice that it's no longer accepting production credential requests for new 2026-27 quarterly update products. The sandbox remains open, so you can build and test everything above today, but read that notice on the <a href="https://developer.service.hmrc.gov.uk/api-documentation/docs/api/service/self-employment-business-api/5.0">Self Employment Business API page</a> before you commit to a go-live date.</p>
<p>I work on <a href="https://taptax.co.uk?utm_source=freecodecamp&amp;utm_medium=editorial&amp;utm_campaign=ebas_fcc_quarterly">TapTax</a>, a Making Tax Digital app for UK sole traders, which is where the code in this article comes from.</p>
<p><em>About the author: Solomon Amos is the founder of TapTax and built its HMRC Making Tax Digital integration. He has spent the last three years as a lead technical architect on HMRC digital modernisation programmes, and holds a PhD in engineering, with research in machine learning. You can find him on</em> <a href="https://www.linkedin.com/in/solomonudoh/"><em>LinkedIn</em></a><em>.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The Node.js and Express.js Handbook for Beginners – Servers, Routes, Routers, and Views Explained ]]>
                </title>
                <description>
                    <![CDATA[ Node.js is a runtime environment for executing JavaScript outside the web browser. From small scripts to large-scale back-end applications, Node.js provides the APIs and tools you need to build JavaSc ]]>
                </description>
                <link>https://www.freecodecamp.org/news/nodejs-and-expressjs-handbook-for-beginners/</link>
                <guid isPermaLink="false">6abe2c47229f69b03c107a96</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Express ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwatobi Sofela ]]>
                </dc:creator>
                <pubDate>Thu, 01 Oct 2026 09:47:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/da9c2a49-21e8-4b9a-8dc3-adac2dcf82d0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Node.js is a runtime environment for executing JavaScript outside the web browser. From small scripts to large-scale back-end applications, Node.js provides the APIs and tools you need to build JavaScript applications for a variety of environments.</p>
<p>But learning Node.js can feel overwhelming. With so many APIs, packages, tools, and concepts to understand, it’s easy to feel lost.</p>
<p>That’s why this book focuses on the fundamental Node.js concepts you need to build practical applications without unnecessary distractions. You’ll learn how Node.js works, how to use its built-in APIs, and how to create applications that interact with files, URLs, events, HTTP requests, and more.</p>
<p>You’ll also learn how Express.js simplifies many common server-side tasks. We’ll explore routes, routers, controllers, views, and other concepts that help you organize and build web applications more efficiently.</p>
<p>Whether you’re learning backend development for the first time or expanding your JavaScript skills beyond the browser, this guide is designed to give you a clear and practical foundation for building with Node.js and Express.js.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-you-should-already-know">What You Should Already Know</a></p>
</li>
<li><p><a href="#heading-the-tools-youll-need">The Tools You'll Need</a></p>
</li>
<li><p><a href="#heading-what-exactly-is-nodejs">What Exactly Is Node.js?</a></p>
<ul>
<li><p><a href="#heading-create-a-new-directory-for-your-project">Create a New Directory for Your Project</a></p>
</li>
<li><p><a href="#heading-create-a-packagejson-file">Create apackage.jsonFile</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-why-nodejs">Why Node.js?</a></p>
<ul>
<li><p><a href="#heading-1-create-a-javascript-file">1. Create a JavaScript File</a></p>
</li>
<li><p><a href="#heading-2-write-your-javascript-program">2. Write your JavaScript Program</a></p>
</li>
<li><p><a href="#heading-3-run-your-javascript-program">3. Run your JavaScript Program</a></p>
</li>
<li><p><a href="#heading-4-automatically-rerun-your-javascript-program">4. Automatically Rerun Your JavaScript Program</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-nodes-module-support">Node's Module Support</a></p>
</li>
<li><p><a href="#heading-create-http-servers-with-nodejs">Create HTTP Servers with Node.js</a></p>
<ul>
<li><p><a href="#heading-initialize-a-new-instance-of-the-http-server-object">Initialize a New Instance of the HTTPServerObject</a></p>
</li>
<li><p><a href="#heading-configure-the-systems-port-where-the-server-should-run">Configure the System's Port Where the Server Should Run</a></p>
</li>
<li><p><a href="#heading-configure-the-server-to-respond-to-client-requests">Configure the Server to Respond to Client Requests</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-exactly-is-expressjs">What Exactly Is Express.js?</a></p>
<ul>
<li><p><a href="#heading-1-install-express">1. Install Express</a></p>
</li>
<li><p><a href="#heading-2-use-expressjs-to-configure-the-projects-nodejs-web-server">2. Use Express.js to Configure the Project's Node.js Web Server</a></p>
</li>
<li><p><a href="#heading-3-run-the-expressjs-web-server">3. Run the Express.js Web Server</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-raw-nodejs-code-vs-expressjs-code">Raw Node.js Code vs. Express.js Code</a></p>
<ul>
<li><p><a href="#heading-example-1-use-raw-nodejs-to-handle-web-requests">Example 1: Use raw Node.js to Handle Web Requests</a></p>
</li>
<li><p><a href="#heading-example-2-use-expressjs-to-handle-web-requests">Example 2: Use Express.js to Handle Web Requests</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-important-stuff-to-know-about-creating-expressjs-web-servers-in-nodejs">Important Stuff to Know About Creating Express.js Web Servers in Node.js</a></p>
<ul>
<li><p><a href="#heading-its-common-to-call-applisten-last">It's Common to Callapp.listen()Last</a></p>
</li>
<li><p><a href="#heading-you-can-set-the-port-number-using-environment-variables">You Can Set the Port Number Using Environment Variables</a></p>
</li>
<li><p><a href="#heading-common-expressjs-response-methods">Common Express.js Response Methods</a></p>
</li>
<li><p><a href="#heading-expresss-response-methods-do-not-terminate-the-http-request-handlers-execution">Express's Response Methods Do Not Terminate the HTTP Request Handler's Execution</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-is-a-route-in-expressjs">What Is a Route in Express.js?</a></p>
<ul>
<li><p><a href="#heading-syntax-of-a-route-in-express">Syntax of a Route in Express</a></p>
</li>
<li><p><a href="#heading-middleware-vs-route-handler">Middleware vs. Route Handler</a></p>
</li>
<li><p><a href="#heading-route-examples-in-expressjs">Route Examples in Express.js</a></p>
</li>
<li><p><a href="#heading-important-things-to-know-about-the-next-parameter">Important Things to Know About thenextParameter</a></p>
</li>
<li><p><a href="#heading-categories-of-middleware-in-expressjs">Categories of Middleware in Express.js</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-is-the-expressrouter-method">What Is the express.Router() Method?</a></p>
<ul>
<li><p><a href="#heading-create-a-directory-for-the-routers">Create a Directory for the Routers</a></p>
</li>
<li><p><a href="#heading-create-the-route-files">Create the Route Files</a></p>
</li>
<li><p><a href="#heading-create-a-mini-app-for-the-book-related-routes-and-middleware">Create a Mini-App for the Book-Related Routes and Middleware</a></p>
</li>
<li><p><a href="#heading-time-to-practice-with-routers">Time to Practice with Routers</a></p>
</li>
<li><p><a href="#heading-update-the-main-express-application">Update the Main Express Application</a></p>
</li>
<li><p><a href="#heading-run-your-expressjs-application">Run your Express.js Application</a></p>
</li>
<li><p><a href="#heading-important-things-to-know-about-express-routers">Important Things to Know About Express Routers</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-is-a-view-in-expressjs">What Is a View in Express.js?</a></p>
<ul>
<li><p><a href="#heading-what-are-static-views-in-expressjs">What Are Static Views in Express.js?</a></p>
</li>
<li><p><a href="#heading-what-are-dynamic-views-in-expressjs">What Are Dynamic Views in Express.js?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-overview">Overview</a></p>
<ul>
<li><a href="#heading-dive-deeper-into-nodejs-and-expressjs">Dive Deeper into Node.js and Express.js</a></li>
</ul>
</li>
</ul>
<h2 id="heading-what-you-should-already-know">What You Should Already Know</h2>
<p>This guide is for readers who know basic JavaScript and want to start building web applications with Node.js and Express.js. You'll get the most value from it if you're familiar with:</p>
<ul>
<li><p>JavaScript fundamentals, including variables, functions, arrays, and objects.</p>
</li>
<li><p>Basic asynchronous JavaScript, including callbacks, promises, and <code>async</code>/<code>await</code>.</p>
</li>
<li><p>Basic HTML, including links, forms, and input elements.</p>
</li>
<li><p>Using a terminal to navigate folders and run commands.</p>
</li>
</ul>
<p>You don't need previous experience with Node.js or Express.js. Familiarity with installing npm packages is helpful, but the examples will guide you through the commands you need.</p>
<h2 id="heading-the-tools-youll-need">The Tools You'll Need</h2>
<p>Before you begin, have a code editor, a web browser, and the following installed:</p>
<ul>
<li><p>Node.js 24.15.0 or later.</p>
</li>
<li><p>npm 11.14.0 or later.</p>
</li>
</ul>
<p>Check your installed versions by running:</p>
<pre><code class="language-console">node --version &amp;&amp; npm --version
</code></pre>
<p>Node.js includes npm, but the bundled version may be older than the one listed here. Check both versions and update npm separately if needed. The <a href="https://codesweetly.com/package-manager-explained">CodeSweetly package manager guide</a> explains how to install, update, and check these tools.</p>
<p>The file-creation commands assume an environment with <code>mkdir</code> and <code>touch</code> available, such as Bash or zsh on macOS or Linux, Git Bash on Windows, or a Linux shell through Windows Subsystem for Linux (WSL). You can also create the files and folders directly in your editor.</p>
<p>Let's get started with Node.js.</p>
<h2 id="heading-what-exactly-is-nodejs">What Exactly Is Node.js?</h2>
<p>Node.js is not a framework or language. Instead, it is an <a href="https://codesweetly.com/asynchronous-javascript">asynchronous</a>, <a href="https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Scripting/Events">event</a>-driven environment that includes tools such as:</p>
<ul>
<li><p>JavaScript engine (Google's V8)</p>
</li>
<li><p>Module system (CommonJS and ES modules)</p>
</li>
<li><p>Operating system APIs (<code>os</code>)</p>
</li>
<li><p>Filesystem access (<code>fs</code>)</p>
</li>
<li><p>Network servers (<code>http</code>, <code>net</code>, <code>https</code>)</p>
</li>
<li><p>Memory management tools</p>
</li>
<li><p>Event loop</p>
</li>
</ul>
<p>These components make up Node.js's core infrastructure, enabling JavaScript to run outside of browsers.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61672d1653401f641ba159b4/2ed752ef-64a7-43a8-9323-53ee00665048.jpg" alt="Node.js explained" style="display: block;" width="1920" height="1920" loading="lazy">

<p>The image above illustrates Node.js as a well-equipped runtime environment providing the infrastructure to build and run any scale of JavaScript applications outside the browser, including servers, CLIs, scripts, and full-stack applications.</p>
<p>Let's discuss the points highlighted in the image by building applications with Node.js and Express.js. To begin, create the project directory.</p>
<h3 id="heading-create-a-new-directory-for-your-project">Create a New Directory for Your Project</h3>
<p>Use the <code>mkdir</code> CLI command to create a new project directory as follows:</p>
<pre><code class="language-console">mkdir codesweetly-nodejs-app-001
</code></pre>
<p><strong>Note:</strong> You can use any name you prefer. In this guide, we'll use <code>codesweetly-nodejs-app-001</code> for demonstration.</p>
<p>Afterward, navigate to your project directory using the command line.</p>
<pre><code class="language-console">cd codesweetly-nodejs-app-001
</code></pre>
<h3 id="heading-create-a-packagejson-file">Create a <code>package.json</code> File</h3>
<p>Use npm to initialize a <a href="https://codesweetly.com/package-json-file-explained"><code>package.json</code> file</a> after navigating into the project directory.</p>
<pre><code class="language-console">npm init -y
</code></pre>
<p>Next, use the following command to delete the optional <code>main</code> field from <code>package.json</code>:</p>
<pre><code class="language-console">npm pkg delete main
</code></pre>
<p><strong>Tip:</strong> The <code>main</code> field in <code>package.json</code> identifies a package's entry point when another program loads the package. We do not need it for this project because we'll run our scripts directly. Leaving it in place is also harmless.</p>
<h2 id="heading-why-nodejs">Why Node.js?</h2>
<p>JavaScript was originally created for web browsers, but other environments also provide the infrastructure to execute it.</p>
<p>Node's 2009 release provided an environment for running JavaScript outside the web browser. For example, let's create a script to run from our system's terminal.</p>
<h3 id="heading-1-create-a-javascript-file">1. Create a JavaScript File</h3>
<p>Create the JavaScript file you want Node to run.</p>
<p>In a shell that supports <code>touch</code>, such as Bash or zsh, use the following command. Otherwise, create the file in your code editor:</p>
<pre><code class="language-console">touch console.js
</code></pre>
<h3 id="heading-2-write-your-javascript-program">2. Write your JavaScript Program</h3>
<p>Open the newly created JavaScript file and write your program:</p>
<pre><code class="language-javascript">console.log("=== Hello from the CodeSweetly Team! ===");
console.log("We hope you have fun Coding Sweetly with Node.js.");
console.log("Thank you for being part of the CodeSweetly community.");
console.log("=== Keep coding. Keep creating. Keep shipping. ===");
</code></pre>
<h3 id="heading-3-run-your-javascript-program">3. Run your JavaScript Program</h3>
<p>Installing Node.js makes the <code>node</code> command available for running a Node application from your command line.</p>
<pre><code class="language-console">node console.js
</code></pre>
<ul>
<li><p><code>node</code>: The command for running Node.js scripts or the REPL.</p>
</li>
<li><p><code>console.js</code>: The JavaScript file you want Node to run.</p>
</li>
</ul>
<p>You can also add the command to the <code>"scripts"</code> field of your project's <code>package.json</code> file:</p>
<pre><code class="language-json">{
  "scripts": {
    "start": "node console.js",
    "test": "echo \"Error: no test specified\" &amp;&amp; exit 1"
  }
}
</code></pre>
<p>With this script in place, you can run your JavaScript program from your terminal like this:</p>
<pre><code class="language-console">npm run start
</code></pre>
<p>Once you execute your script, Node will print the file's output to your terminal. It will look like this:</p>
<pre><code class="language-console">$ npm run start

&gt; codesweetly-nodejs-app-001@1.0.0 start
&gt; node console.js

=== Hello from the CodeSweetly Team! ===
We hope you have fun Coding Sweetly with Node.js.
Thank you for being part of the CodeSweetly community.
=== Keep coding. Keep creating. Keep shipping. ===
</code></pre>
<p>As you can see, we've successfully executed JavaScript outside a web browser. That's precisely what Node.js helps us with. It is an asynchronous, event-driven runtime environment for running JavaScript code outside the web browser. You can also automate rerunning your program. Let's discuss how.</p>
<h3 id="heading-4-automatically-rerun-your-javascript-program">4. Automatically Rerun Your JavaScript Program</h3>
<p>By default, Node.js requires you to manually rerun your JavaScript file each time you make changes.</p>
<p>Repeating the manual process of executing your application as you make changes can be burdensome. Luckily, Node provides the <code>--watch</code> flag for automating the process:</p>
<pre><code class="language-console">node --watch filename.extension
</code></pre>
<p>The snippet above uses the <code>--watch</code> flag to start Node.js in watch mode. This causes Node to re-execute the specified script whenever you update any of the files in its dependency graph.</p>
<ul>
<li><p><code>node</code>: The command for running Node.js scripts or the REPL.</p>
</li>
<li><p><code>--watch</code>: The flag for activating Node's watch mode.</p>
</li>
<li><p><code>filename.extension</code>: The script you want Node to execute.</p>
</li>
</ul>
<p><strong>Tip:</strong> Stop the running process using <code>Ctrl + C</code> on Windows, macOS, or Linux.</p>
<p>The <code>--watch</code> flag, by default, watches the entry point and all the modules it depends on. In other words, the watch command causes Node to monitor:</p>
<ul>
<li><p>The entry file (<code>filename.extension</code>)</p>
</li>
<li><p>All files imported by the entry file</p>
</li>
<li><p>All files imported in those imports</p>
</li>
<li><p>And so on through the whole dependency graph</p>
</li>
</ul>
<p>Node's default watch mode does not watch unrelated files. If you want Node to watch other files, use the <code>--watch-path</code> flag:</p>
<pre><code class="language-console">node --watch-path=./src --watch-path=./tests filename.extension
</code></pre>
<p>The <code>--watch-path</code> flag, in the snippet above, tells Node to watch all files in the <code>src</code> and <code>tests</code> directories, even if they are not in the entry point's dependency graph.</p>
<ul>
<li><p><code>node</code>: The command for running Node.js scripts or the REPL.</p>
</li>
<li><p><code>--watch-path</code>: The flag for specifying the path you want Node to watch for changes.</p>
</li>
<li><p><code>filename.extension</code>: The script you want Node to execute.</p>
</li>
</ul>
<p><strong>Tip:</strong></p>
<ul>
<li><p>The <code>--watch-path</code> flag causes Node to ignore the dependency graph's modules unless they are part of <code>--watch-path</code>. Instead, Node.js will watch only the exact paths you specified, and nothing else.</p>
</li>
<li><p><code>--watch-path</code> enables watch mode itself. If you also pass <code>--watch</code>, Node still watches only the specified paths.</p>
</li>
<li><p><code>--watch-path</code> is supported on macOS and Windows, but not Linux. On Linux, use <code>--watch</code> to monitor the entry file and its dependencies.</p>
</li>
<li><p>Watch commands do not reload the browser automatically. They simply detect file changes, so Node reruns the program you specified. As such, if your program sends data to browsers, you will need to refresh the browser yourself or use a third-party tool for auto-reloading.</p>
</li>
</ul>
<p>Node.js supports two module systems. Let's learn about them.</p>
<h2 id="heading-nodes-module-support">Node's Module Support</h2>
<p>Node.js supports both CommonJS (<code>.cjs</code>) and ECMAScript (<code>.mjs</code>) <a href="https://codesweetly.com/javascript-modules-tutorial">modules</a>. This allows you to use your preferred module type to create Node.js applications.</p>
<p>For example, below is a <code>script.mjs</code> JavaScript file. Node will treat it as an ECMAScript module because it has a <code>.mjs</code> file extension.</p>
<p><code>script.mjs</code></p>
<pre><code class="language-javascript">import http from "node:http";
</code></pre>
<p>On the other hand, Node will regard the <code>script.cjs</code> JavaScript file below as a CommonJS module because it has a <code>.cjs</code> file extension.</p>
<p><code>script.cjs</code></p>
<pre><code class="language-javascript">const http = require("node:http");
</code></pre>
<p>Suppose you want to specify the module type for the <code>.js</code> files in your project. In that case, specify a <code>type</code> field in your <code>package.json</code> file like so:</p>
<p><code>package.json</code></p>
<pre><code class="language-json">{
  "scripts": {
    "start": "node console.js",
    "test": "echo \"Error: no test specified\" &amp;&amp; exit 1"
  },
  "type": "module",
  "license": "ISC"
}
</code></pre>
<p>The <code>"type": "module"</code> field in the snippet above makes Node treat <code>.js</code> files governed by this <code>package.json</code> as ES modules. A nested <code>package.json</code> establishes its own scope. <code>.mjs</code> and <code>.cjs</code> files retain their respective module types.</p>
<p>Set the <code>"type"</code> field to <code>"commonjs"</code> to make Node treat <code>.js</code> files in that scope as CommonJS.</p>
<p>Some notes:</p>
<ul>
<li><p>The ES module system is the official standard for JavaScript.</p>
</li>
<li><p>Kevin Dangoor started the project that became CommonJS in January 2009, before JavaScript had an official standard module system.</p>
</li>
<li><p>ES modules became part of the ECMAScript standard in 2015. CommonJS remains supported in Node.js.</p>
</li>
</ul>
<p>In this guide, we'll mainly use Node.js with ES modules, since ES modules are JavaScript's standard module system. To follow the examples, make sure the <code>"type"</code> field in your <code>package.json</code> file is set to <code>"module"</code>. You can do this by running the following command from your project directory:</p>
<pre><code class="language-console">npm pkg set type=module
</code></pre>
<p>When relevant, we'll compare CommonJS syntax.</p>
<p>Let's now discuss using Node.js to create web servers that receive and respond to requests from browsers.</p>
<h2 id="heading-create-http-servers-with-nodejs">Create HTTP Servers with Node.js</h2>
<p>An HTTP server lets your JavaScript application handle requests from clients, such as browsers, and send responses.</p>
<p>There are three main steps to configuring an app's HTTP server:</p>
<ol>
<li><p>Create a new instance of the HTTP <code>Server</code> object.</p>
</li>
<li><p>Specify the system's port where you want the server to run.</p>
</li>
<li><p>Define how the server should respond to client requests.</p>
</li>
</ol>
<p>Let's discuss the three steps in detail. To start, create an ES module for your project.</p>
<pre><code class="language-console">touch server.js
</code></pre>
<p>Afterward, open the newly created module and initialize a new instance of the HTTP <code>Server</code> object.</p>
<h3 id="heading-initialize-a-new-instance-of-the-http-server-object">Initialize a New Instance of the HTTP <code>Server</code> Object</h3>
<p>Node provides the <code>createServer</code> method for creating an instance of the HTTP <code>Server</code> object (<code>http.Server</code>).</p>
<p>You can use it in your project by importing it from Node's <code>http</code> module as follows:</p>
<p><code>server.js</code></p>
<pre><code class="language-javascript">import { createServer } from "node:http";

const server = createServer();
</code></pre>
<p>Here's what's going on:</p>
<ul>
<li><p><code>import</code> statement: Imports the <code>createServer</code> API from Node's <code>http</code> library to the <code>server.js</code> ES module.</p>
</li>
<li><p><code>server</code>: A variable for storing the HTTP <code>Server</code> object that the <code>createServer()</code> function outputs.</p>
</li>
</ul>
<p>Here's the CommonJS alternative:</p>
<p><code>server.cjs</code></p>
<pre><code class="language-javascript">const http = require("node:http");

const server = http.createServer();
</code></pre>
<p>Once you have created the local HTTP <code>Server</code> object, specify the server's port.</p>
<h3 id="heading-configure-the-systems-port-where-the-server-should-run">Configure the System's Port Where the Server Should Run</h3>
<p>The HTTP <code>Server</code> object provides a <code>listen()</code> method to configure the port on which the server should run and accept client requests.</p>
<h4 id="heading-syntax">Syntax</h4>
<pre><code class="language-javascript">import { createServer } from "node:http";

const server = createServer();

server.listen(port, hostname, backlog, callback);
</code></pre>
<p>This form of the <code>listen()</code> method accepts the following arguments:</p>
<ul>
<li><p><code>port</code>: (number) The port number where the server should run and listen for client requests. If omitted, the operating system will assign any unused port. You can use <code>server.address().port</code> to retrieve the port the server is listening on after it starts listening for client connections.</p>
</li>
<li><p><code>hostname</code>: (string) The hostname or IP address on which the server should accept client connections. If omitted, the server will default to either an unspecified <a href="https://en.wikipedia.org/wiki/IPv6_address#Unspecified_address">IPv6 (<code>::</code>)</a> address or an <a href="https://en.wikipedia.org/wiki/0.0.0.0">IPv4 (<code>0.0.0.0</code>)</a> address. These unspecified addresses bind to all network interfaces for the applicable address family. You can use <code>server.address().address</code> to retrieve the bound IP address once it starts listening for client connections.</p>
</li>
<li><p><code>backlog</code>: (number) Maximum length of the pending connections' queue. 511 is the default value.</p>
</li>
<li><p><code>callback</code>: (function) The function to execute once the server starts listening for client requests.</p>
</li>
</ul>
<h4 id="heading-example">Example</h4>
<p><code>server.js</code></p>
<pre><code class="language-javascript">import { createServer } from "node:http";

const server = createServer();

server.listen(3000, "127.0.0.1", 511, () =&gt; {
  const info = server.address();
  console.log(`Server running at http://${info.address}:
${info.port}/`);
});
</code></pre>
<p>Here's what's going on:</p>
<ul>
<li><p><code>import</code> statement: Imports the <code>createServer</code> API from Node's <code>http</code> library to the <code>server.js</code> ES module.</p>
</li>
<li><p><code>server</code>: A variable for storing the HTTP <code>Server</code> object that the <code>createServer()</code> function outputs.</p>
</li>
<li><p><code>server.listen()</code>: Starts the server to listen for client connections. (Tip: The method emits a <a href="https://nodejs.org/api/net.html#event-listening"><code>listening</code></a> event once the server starts successfully.)</p>
</li>
</ul>
<p>Here's the CommonJS alternative:</p>
<p><code>server.cjs</code></p>
<pre><code class="language-javascript">const http = require("node:http");

const server = http.createServer();

server.listen(3000, "127.0.0.1", 511, () =&gt; {
  const info = server.address();
  console.log(`Server running at http://${info.address}:
${info.port}/`);
});
</code></pre>
<h4 id="heading-what-is-the-127001-address">What is the <code>127.0.0.1</code> address?</h4>
<p>The <code>127.0.0.1</code> address is an IPv4 loopback address that refers to your local computer. The hostname <code>localhost</code> also refers to your local computer, but it may resolve to <code>127.0.0.1</code> or the IPv6 loopback address, <code>::1</code>. You can use it as follows:</p>
<p><code>server.js</code></p>
<pre><code class="language-javascript">import { createServer } from "node:http";

const server = createServer();

server.listen(3000, "localhost", 511, () =&gt; {
  console.log("Server running at http://localhost:3000/");
});
</code></pre>
<p>If you run this server and visit its web address, the browser will wait for a response because we have not yet set up a request handler. Let's configure that now.</p>
<h3 id="heading-configure-the-server-to-respond-to-client-requests">Configure the Server to Respond to Client Requests</h3>
<p>The <code>createServer()</code> method accepts a <code>requestListener</code> callback that is invoked automatically whenever the server receives an HTTP request. Node allows you to use this callback to respond to requests.</p>
<h4 id="heading-syntax">Syntax</h4>
<p>The <code>createServer()</code> method accepts two optional arguments. Here's the syntax:</p>
<pre><code class="language-javascript">import { createServer } from "node:http";

const server = createServer(options, callback);
</code></pre>
<ul>
<li><p><code>options</code>: An object for customizing the server's behavior.</p>
</li>
<li><p><code>callback</code>: The <code>requestListener</code> function for handling and responding to client requests. It accepts two parameters:</p>
</li>
</ul>
<pre><code class="language-javascript">import { createServer } from "node:http";

const server = createServer(options, function (request, response) {
  // the requestListener function's body
});
</code></pre>
<ul>
<li><p><code>request</code> parameter: An <code>http.IncomingMessage</code> object that provides details about the client request.</p>
</li>
<li><p><code>response</code> parameter: An <code>http.ServerResponse</code> object for responding to the client requests.</p>
</li>
</ul>
<p>Providing the <code>requestListener</code> callback function as <code>createServer</code>'s second argument causes Node.js to automatically register it as a listener for the <a href="https://nodejs.org/api/http.html#event-request"><code>"request"</code></a> event. So, the syntax above is equivalent to:</p>
<pre><code class="language-javascript">import { createServer } from "node:http";

const server = createServer(options);

server.on("request", function (request, response) {
  // the requestListener function's body
});
</code></pre>
<p><code>server.on("request", callback)</code> tells the server to listen for a request event and execute the callback on such an event.</p>
<h4 id="heading-example">Example</h4>
<p><code>server.js</code></p>
<pre><code class="language-javascript">// Add the Node.js HTTP module
import { createServer } from "node:http";

// Specify the hostname and port to run the server
const hostname = "localhost";
const port = 3000;

// Create a new Server instance with a requestListener callback
const server = createServer((req, res) =&gt; {
  res.statusCode = 200; // Set an OK success (200) response status code
  res.setHeader("Content-Type", "text/plain"); // Define the media type of the response data
  res.end("Hello World!"); // Specify the response data and close the response stream
});

// Run the server on the specified port and hostname
server.listen(port, hostname, () =&gt; {
  console.log(`Server running at http://${hostname}:
${port}/`);
});
</code></pre>
<p>The snippet above used the <code>requestListener</code> callback function's response parameter to configure the server to respond to client requests. Here's the <code>server.on("request", callback)</code> alternative:</p>
<p><code>server.js</code></p>
<pre><code class="language-javascript">// Add the Node.js HTTP module
import { createServer } from "node:http";

// Specify the hostname and port to run the server
const hostname = "localhost";
const port = 3000;

// Create a new Server instance
const server = createServer();

// Listen for a request event and execute the requestListener callback
server.on("request", (req, res) =&gt; {
  res.statusCode = 200; // Set an OK success (200) response status code
  res.setHeader("Content-Type", "text/plain"); // Define the media type of the response data
  res.end("Hello World!"); // Specify the response data and close the response stream
});

// Run the server on the specified port and hostname
server.listen(port, hostname, () =&gt; {
  console.log(`Server running at http://${hostname}:
${port}/`);
});
</code></pre>
<p>Now that your server is set up and the port configured, you can run the application.</p>
<pre><code class="language-console">node server.js
</code></pre>
<ul>
<li><p><code>node</code>: The command for running Node.js scripts or the REPL.</p>
</li>
<li><p><code>server.js</code>: The JavaScript file you want Node to run.</p>
</li>
</ul>
<p>While the server is running, if users request the app's resource at the server's web address, they will see a <code>Hello World!</code> response as illustrated in the following image.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61672d1653401f641ba159b4/29085aef-058e-44f3-8e9a-fad24dc387b5.jpg" alt="The browser displays the &quot;Hello World!&quot; text at http://localhost:3000/" style="display: block;" width="1920" height="1080" loading="lazy">

<p><strong>Tip:</strong> Stop the running process using <code>Ctrl + C</code> on Windows, macOS, or Linux.</p>
<p>Although the <code>node:http</code> module is Node.js's native API for handling HTTP requests, developers typically use frameworks to simplify the process. Some popular Node.js web frameworks include Express.js, Fastify, and Koa.js. Let's use Express.js as an example to see how a framework can simplify HTTP request handling in Node.</p>
<h2 id="heading-what-exactly-is-expressjs">What Exactly Is Express.js?</h2>
<p>Express.js is a web framework that simplifies server-side HTTP request handling in Node.js. It extends Node's HTTP module with built-in features such as routing and middleware, helping developers to build web applications and APIs efficiently without repetitive code. Here's how to use it in your Node.js project.</p>
<h3 id="heading-1-install-express">1. Install Express</h3>
<pre><code class="language-console">npm install express@5.2.1
</code></pre>
<h3 id="heading-2-use-expressjs-to-configure-the-projects-nodejs-web-server">2. Use Express.js to Configure the Project's Node.js Web Server</h3>
<p>Open the JavaScript server file and use Express.js to set up a web server:</p>
<p><code>server.js</code></p>
<pre><code class="language-javascript">// Add the Express module
import express from "express";

// Create a new Express application instance
const app = express();

// Set the port number where the server will run and listen for requests
const port = 3000;

// Create a route handler for GET requests to the "/" path
app.get("/", (req, res) =&gt; res.send("Hello, world!"));

// Run the server on the specified port
app.listen(port, () =&gt; {
  console.log(`Server running at http://localhost:${port}`);
});
</code></pre>
<p>Here's what the code above does:</p>
<ul>
<li><p>Creates an Express application instance to access Express APIs, such as routers and middleware, simplifying HTTP request handling in Node.js.</p>
</li>
<li><p>Uses Express's <code>app.get()</code> method to define how the server handles HTTP GET requests to the <code>/</code> route.</p>
</li>
<li><p>Uses Express's <code>res.send()</code> method to send an HTTP response and automatically set the appropriate headers based on the data type provided.</p>
</li>
<li><p>Uses Express's <code>app.listen()</code> method to start the Node.js HTTP server on a specific port and execute a callback when the server begins listening for client requests.</p>
</li>
</ul>
<p><strong>Tip:</strong></p>
<ul>
<li><p>In the statement <code>app.get("/", (req, res) =&gt; res.send("Hello, world!"))</code>,</p>
<ul>
<li><p><code>app.get()</code> is the Express routing method.</p>
</li>
<li><p><code>"/"</code> is the route path.</p>
</li>
<li><p><code>(req, res) =&gt; ...</code> is the route handler function.</p>
</li>
<li><p>The entire statement is the route (or route definition).</p>
</li>
</ul>
</li>
<li><p>The objects passed to an Express route handler's parameters (<code>req</code> and <code>res</code>) are enhanced versions of Node's <code>http.IncomingMessage</code> and <code>http.ServerResponse</code> objects. Express adds properties and helper methods to simplify handling HTTP requests and responses.</p>
</li>
<li><p>The order of route definitions matters. Express processes matching routes in the order they are defined. A handler can end the response or pass control to another handler using <code>next()</code>.</p>
</li>
</ul>
<h3 id="heading-3-run-the-expressjs-web-server">3. Run the Express.js Web Server</h3>
<p>Use the <code>node</code> command followed by the script's filename to run a Node.js script, including one that uses Express.js to handle HTTP requests.</p>
<pre><code class="language-console">node server.js
</code></pre>
<p>Open <code>http://localhost:3000</code> in your browser to see <code>Hello, world!</code>.</p>
<p><strong>Tip:</strong> Stop the server using <code>Ctrl + C</code> on Windows or <code>Control + C</code> on macOS.</p>
<h2 id="heading-raw-nodejs-code-vs-expressjs-code">Raw Node.js Code vs. Express.js Code</h2>
<p>While you can use Node's native HTTP API to handle web requests, it requires manual configuration, such as inspecting each request's method and URL, setting response headers, and converting objects to JSON.</p>
<p>Express abstracts repetitive request-handling logic into declarative methods, simplifying Node.js web server development.</p>
<p>Below are two examples comparing Node.js web server code written with the native HTTP module and with Express methods.</p>
<h3 id="heading-example-1-use-raw-nodejs-to-handle-web-requests">Example 1: Use raw Node.js to Handle Web Requests</h3>
<p>The following web server uses Node's built-in HTTP module to handle GET requests to two endpoints—home (<code>/</code>) and books (<code>/books</code>)—and return a 404 response for unmatched requests.</p>
<p><code>server.js</code></p>
<pre><code class="language-javascript">import { createServer } from "node:http";
const port = 3000;

const server = createServer((req, res) =&gt; {
  if (req.method === "GET" &amp;&amp; req.url === "/") {
    res.writeHead(200, { "Content-Type": "text/plain" });
    res.end("Welcome to the homepage!");
  } else if (req.method === "GET" &amp;&amp; req.url === "/books") {
    const books = [
      { id: 1, name: "Code React Sweetly" },
      { id: 2, name: "Creating NPM Package" },
    ];
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(books));
  } else {
    res.writeHead(404, { "Content-Type": "text/plain" });
    res.end("Page not found");
  }
});

server.listen(port, () =&gt; {
  console.log(`Raw Node server running at http://localhost:${port}`);
});
</code></pre>
<p>The snippet above uses Node's native <code>http</code> module to build a web server that accepts and responds to browser connections. In this raw Node.js example, you manually handle the following tasks:</p>
<ul>
<li><p><strong>Routing to the correct endpoint:</strong> <code>if (req.method === "..." &amp;&amp; req.url === "...")</code></p>
</li>
<li><p><strong>Response header configuration:</strong> <code>res.writeHead(...)</code></p>
</li>
<li><p><strong>JSON formatting:</strong> <code>JSON.stringify(...)</code></p>
</li>
</ul>
<p>Now, let's see how to build a similar web server using Express. Run these examples one at a time, replacing the contents of <code>server.js</code> and restarting the server.</p>
<h3 id="heading-example-2-use-expressjs-to-handle-web-requests">Example 2: Use Express.js to Handle Web Requests</h3>
<p>The following web server uses Express routes to handle GET requests to two endpoints—home (<code>/</code>) and books (<code>/books</code>)—and return a 404 response for unmatched requests.</p>
<p><code>server.js</code></p>
<pre><code class="language-javascript">import express from "express";
const app = express();
const port = 3000;

app.get("/", (req, res) =&gt; {
  res.send("Welcome to the homepage!");
});

app.get("/books", (req, res) =&gt; {
  const books = [
    { id: 1, name: "Code React Sweetly" },
    { id: 2, name: "Creating NPM Package" },
  ];
  res.json(books);
});

app.use((req, res) =&gt; {
  res.status(404).send("Page not found");
});

app.listen(port, () =&gt; {
  console.log(`Express server running at http://localhost:${port}`);
});
</code></pre>
<p>The snippet above uses Express.js to build a web server that accepts and responds to browser connections. Express simplifies the following tasks:</p>
<ul>
<li><p><strong>Routing logic:</strong> Express.js provides simple route declarations like <code>app.get()</code>, <code>app.post()</code>, and <code>app.delete()</code>, eliminating the need for complex conditional ogic to check <code>req.url</code> and <code>req.method</code> for each route.</p>
</li>
<li><p><strong>Response header configuration:</strong> Express's <code>res.send()</code> method automatically sets appropriate response headers based on the response body. Use <code>res.status()</code> when you need to set a status code, as in the 404 handler above.</p>
</li>
<li><p><strong>JSON formatting:</strong> Express's <code>res.json()</code> method automatically converts JavaScript objects into valid JSON responses, handling serialization and headers for you.</p>
</li>
</ul>
<h2 id="heading-important-stuff-to-know-about-creating-expressjs-web-servers-in-nodejs">Important Stuff to Know About Creating Express.js Web Servers in Node.js</h2>
<p>Keep the following key points in mind when creating Express.js servers in your Node.js project.</p>
<h3 id="heading-its-common-to-call-applisten-last">It's Common to Call <code>app.listen()</code> Last</h3>
<p>It's common to call <code>app.listen()</code> last in an Express.js app. This way, all your routes, middleware, and error handlers are set up before the server starts listening for requests.</p>
<h3 id="heading-you-can-set-the-port-number-using-environment-variables">You Can Set the Port Number Using Environment Variables</h3>
<p>To read the port number from an environment variable, replace the existing <code>port</code> declaration with the following:</p>
<pre><code class="language-javascript">const port = process.env.PORT || 3000;
</code></pre>
<p>The above snippet instructs Node to use the <code>PORT</code> environment variable's value, or default to <code>3000</code> if it's unset or empty. In a shell such as Bash, the following command sets <code>PORT</code> to <code>8000</code> for this run of the server:</p>
<pre><code class="language-console">PORT=8000 node server.js
</code></pre>
<h3 id="heading-common-expressjs-response-methods">Common Express.js Response Methods</h3>
<p>Here are some common response methods you'll use in Express.js:</p>
<ul>
<li><p><code>res.send()</code>: Send an HTTP response to the client and automatically set the appropriate response headers based on the data type provided as the method's argument.</p>
</li>
<li><p><code>res.json()</code>: Send JSON responses to the client and automatically set the response's <code>Content-Type</code> header to <code>application/json</code>.</p>
</li>
<li><p><code>res.redirect()</code>: Redirect the client's request to a different URL.</p>
</li>
<li><p><code>res.render()</code>: Render a template engine's view and send the resulting HTML string to the client.</p>
</li>
<li><p><code>res.status()</code>: Set the response's HTTP status code without ending the request-response cycle. You can chain other response methods to it, for example, <code>res.status(404).send("404 Error: Page not found")</code>. You do not need to call this method to use the default status code of <code>200</code>.</p>
</li>
<li><p><code>res.end()</code>: End the response. When called without a data argument, it sends no additional response body data. It can also accept data to send before ending the response.</p>
</li>
</ul>
<h3 id="heading-expresss-response-methods-do-not-terminate-the-http-request-handlers-execution">Express's Response Methods Do Not Terminate the HTTP Request Handler's Execution</h3>
<p>While response methods like <code>res.send()</code> close the HTTP request-response cycle, they do not end the execution of the route handler function.</p>
<p><strong>Here's an example:</strong></p>
<pre><code class="language-javascript">app.get("/", (req, res) =&gt; {
  // This sends a text response to the client and ends the request-response cycle, but does not end the route handler's execution
  res.send("Hello, client!");

  // This logs the string to the console because the function is still running
  console.log("Hello, devs!");

  // This will cause an error because the request-response cycle is closed, so you cannot send additional responses to the client during the callback's execution
  res.send("Hello, client again!");
});
</code></pre>
<p>The <code>console.log()</code> statement in the example above works because the request handler is still running. The <code>res.send("Hello, client!")</code> line ends the response to that request. It does not stop the callback's execution.</p>
<p>Note that the <code>app.get(...)</code> code in the snippet above is called an Express route. But what exactly is a route in Express.js? Let's discuss it now.</p>
<h2 id="heading-what-is-a-route-in-expressjs">What Is a Route in Express.js?</h2>
<p>A route in Express.js consists of an HTTP method, a URL path, and a chain of one or more middleware functions for processing client requests to an endpoint.</p>
<p><strong>Tip:</strong></p>
<ul>
<li><p>An endpoint consists of an HTTP method and a URL path. (Endpoint = HTTP method + URL path)</p>
</li>
<li><p>The route includes an HTTP method, a URL path, and the server-side logic that handles client requests. (Route = Server HTTP method + Server URL path + handler function)</p>
</li>
</ul>
<h3 id="heading-syntax-of-a-route-in-express">Syntax of a Route in Express</h3>
<pre><code class="language-javascript">app.METHOD(PATH, HANDLER);
</code></pre>
<ul>
<li><p><code>app</code>: An Express application instance that provides access to Express APIs, including methods and middleware, to simplify HTTP request handling in Node.js.</p>
</li>
<li><p><code>METHOD</code>: A lowercase HTTP request method, such as <code>get</code>, <code>post</code>, or <code>delete</code>.</p>
</li>
<li><p><code>PATH</code>: The URL path the route will handle. It can be a string or a regular expression. Express 4 also supports string patterns whose syntax differs in Express 5.</p>
</li>
<li><p><code>HANDLER</code>: The callback (or middleware) function that Express executes when the request and route endpoints correspond. The handler may be:</p>
<ul>
<li><p>A single function</p>
</li>
<li><p>More than one function</p>
</li>
<li><p>An array of functions</p>
</li>
<li><p>A combination of an array of functions and individual function arguments.</p>
</li>
</ul>
</li>
</ul>
<p><strong>Tip:</strong></p>
<ul>
<li><p>You can use the <code>app.all()</code> method to set up handler functions that run for every supported HTTP request method on a specific <code>PATH</code>.</p>
</li>
<li><p>The <code>app.use()</code> method lets you set up middleware functions that can run for all HTTP request methods and paths when a request reaches them. If you specify a path argument, <code>app.use()</code> matches that path and its subpaths. For example, <code>/book</code> matches <code>/book</code>, <code>/book/dashboard</code>, and <code>/book/dashboard/author/202605</code>.</p>
</li>
</ul>
<h3 id="heading-middleware-vs-route-handler">Middleware vs. Route Handler</h3>
<p>Developers often use the term "route handler" to describe the final function responsible for resolving the request and sending the response, whereas preceding reusable functions are referred to as "middleware".</p>
<pre><code class="language-javascript">app.METHOD(PATH, MIDDLEWARE1, MIDDLEWARE2, HANDLER);
</code></pre>
<p><strong>Note:</strong> Express does not mandate distinct names or categories for functions attached to a route. From Express's perspective, they are all handler functions, with <code>next()</code> passing control from one to the next. However, developers often differentiate between middleware and route handlers to enhance code organization, readability, and communication.</p>
<ul>
<li><p>Middleware functions, often stored in a <code>/middlewares</code> directory, serve as utility components that perform cross-cutting or preparatory tasks such as authentication, validation, logging, and data loading. These functions are often reusable and typically invoke <code>next()</code> to pass control to the next function.</p>
</li>
<li><p>Route handlers, sometimes organized as controllers in a <code>/controllers</code> directory, contain the business logic that generates the endpoint's primary response, such as <code>getUserProfile</code>, <code>createBlogPost</code>, or <code>deleteAccount</code>. These functions may be specific to a route and usually conclude the request-response cycle by invoking methods such as <code>res.send()</code> or <code>res.json()</code>.</p>
</li>
</ul>
<h3 id="heading-route-examples-in-expressjs">Route Examples in Express.js</h3>
<p>Here are some examples of routes in Express.js.</p>
<h4 id="heading-express-route-with-a-single-handler-function">Express route with a single handler function</h4>
<pre><code class="language-javascript">app.get("/single/handler", (req, res) =&gt; {
  res.send("Request handled with a single handler function!");
});
</code></pre>
<p>The example above uses a single callback function to handle GET requests to the <code>GET /single/handler</code> endpoint. The handler function, in this case, is an inline controller. A common approach is to keep controllers in a <code>controllers</code> directory. This makes the code easier to test and maintain while keeping the route definition readable.</p>
<p><strong>Here's an example:</strong></p>
<p>The following example shows a <code>GET /single/handler</code> route with a modular controller that has been moved to its own module (<code>controllers/single-handler.js</code>). This approach separates route definitions from the request-handling logic that generates the endpoint's response.</p>
<pre><code class="language-javascript">import express from "express";
import * as controller from "./controllers/single-handler.js";

const app = express();
const port = 3000;

app.get("/single/handler", controller.singleHandler);

app.listen(port, () =&gt; {
  console.log(`Express server running at http://localhost:${port}`);
});
</code></pre>
<p>Below is an example of a controller file that contains only the <code>singleHandler</code> handler function.</p>
<p><code>controllers/single-handler.js</code></p>
<pre><code class="language-javascript">function singleHandler(req, res) {
  res.send("Request handled with a single handler function!");
}

export { singleHandler };
</code></pre>
<p><strong>Tip:</strong> A controller is a route handler or group of handlers that manages requests for specific endpoints. Controllers are often placed in separate modules to improve code organization and maintain a clear separation of concerns.</p>
<h4 id="heading-express-route-with-multiple-middleware-functions">Express route with multiple middleware functions</h4>
<pre><code class="language-javascript">app.get(
  "/multiple/middleware",
  (req, res, next) =&gt; {
    console.log("First callback: passing control to the next middleware &gt;");
    next();
  },
  (req, res, next) =&gt; {
    console.log("Second callback: passing control to the next middleware &gt;");
    next();
  },
  (req, res) =&gt; {
    res.send("Request handled with multiple middleware functions!");
  },
);
</code></pre>
<p>The example above uses multiple middleware functions to handle GET requests to the <code>/multiple/middleware</code> path.</p>
<h4 id="heading-express-route-with-an-array-of-middleware-functions">Express route with an array of middleware functions</h4>
<pre><code class="language-javascript">const arrayOfCallbacks = [
  function callback1(req, res, next) {
    console.log("First callback: passing control to the next middleware &gt;");
    next();
  },
  function callback2(req, res, next) {
    console.log("Second callback: passing control to the next middleware &gt;");
    next();
  },
  function callback3(req, res) {
    res.send("Request handled with an array of middleware functions!");
  },
];

app.get("/array/middleware", arrayOfCallbacks);
</code></pre>
<p>The example above uses an array of callback functions to handle GET requests to the <code>GET /array/middleware</code> endpoint.</p>
<h4 id="heading-express-route-with-a-combination-of-an-array-of-middleware-functions-and-individual-callbacks">Express route with a combination of an array of middleware functions and individual callbacks</h4>
<pre><code class="language-javascript">const arrayOfCallbacks = [
  function callback1(req, res, next) {
    console.log("First callback: passing control to the next middleware &gt;");
    next();
  },
  function callback2(req, res, next) {
    console.log("Second callback: passing control to the next middleware &gt;");
    next();
  },
];

app.get(
  "/mix/middleware",
  arrayOfCallbacks,
  (req, res, next) =&gt; {
    console.log("Third callback: passing control to the next middleware &gt;");
    next();
  },
  (req, res, next) =&gt; {
    console.log("Fourth callback: passing control to the next middleware &gt;");
    next();
  },
  (req, res) =&gt; {
    res.send(
      "Request handled with a combination of an array of middleware functions and independent callback arguments!",
    );
  },
);
</code></pre>
<p>The example above uses both an array of callbacks and individual function arguments to handle GET requests to the <code>/mix/middleware</code> path.</p>
<h3 id="heading-important-things-to-know-about-the-next-parameter">Important Things to Know About the <code>next</code> Parameter</h3>
<ul>
<li><p><code>next()</code> passes control to the next middleware function in the stack.</p>
</li>
<li><p><code>next("route")</code> skips the remaining handlers for the current route and continues looking for a matching route. Matching considers both the request method and path. It is only effective within middleware functions defined for <code>app.METHOD()</code> or <code>router.METHOD()</code>.</p>
</li>
<li><p><code>next(new Error(value))</code> passes control to the next error-handling middleware.</p>
</li>
<li><p>If the currently running middleware does not end the request-response cycle, include a <code>next</code> parameter and invoke it within the callback to pass control to the next middleware. Otherwise, the client's request will remain pending.</p>
</li>
</ul>
<h3 id="heading-categories-of-middleware-in-expressjs">Categories of Middleware in Express.js</h3>
<p>Express middleware can be classified into five primary categories:</p>
<ul>
<li><p>Application-level</p>
</li>
<li><p>Router-level</p>
</li>
<li><p>Error-handling</p>
</li>
<li><p>Built-in</p>
</li>
<li><p>Third-party</p>
</li>
</ul>
<p>These categories describe where middleware is mounted, how it behaves, or its source.</p>
<h4 id="heading-application-level-middleware">Application-level middleware</h4>
<p>This type of middleware is attached to the Express application instance (<code>app</code>).</p>
<p><strong>Example:</strong></p>
<pre><code class="language-javascript">import express from "express";
const app = express();

app.get("/profile", (req, res, next) =&gt; {
  console.log("Hi, there!");
  next();
});
</code></pre>
<h4 id="heading-router-level-middleware">Router-level middleware</h4>
<p>This middleware is attached to an Express Router instance (<code>router</code>). It operates similarly to application-level middleware but is mounted on a router instead of the application.</p>
<p><strong>Example:</strong></p>
<pre><code class="language-javascript">import express from "express";
const router = express.Router();

router.get("/profile", (req, res, next) =&gt; {
  console.log("Hi, there!");
  next();
});
</code></pre>
<h4 id="heading-error-handling-middleware">Error-handling middleware</h4>
<p>This middleware handles errors during request processing and is identified by its four-parameter signature.</p>
<pre><code class="language-javascript">import express from "express";
const app = express();

app.use((err, req, res, next) =&gt; {
  console.error(err);
  res.status(500).send("Error 500: Encountered an unexpected error");
});
</code></pre>
<p><strong>Note:</strong></p>
<ul>
<li><p>All four parameters must be present, even if some are unused. Otherwise, Express won't recognize the function as error-handling middleware. Express uses the number of declared parameters, not their names, to identify such middleware.</p>
</li>
<li><p>Define error-handling middleware after the routes and middleware whose errors it should handle. This placement ensures it processes errors passed from preceding middleware functions.</p>
</li>
</ul>
<h4 id="heading-built-in-middleware">Built-in middleware</h4>
<p>These middleware functions are included with Express and available by default, without requiring additional package installations.</p>
<p><strong>Examples:</strong></p>
<pre><code class="language-javascript">import express from "express";

express.static("public");
express.json();
express.urlencoded();
</code></pre>
<h4 id="heading-third-party-middleware">Third-party middleware</h4>
<p>These middleware functions are provided by external packages developed and shared by the broader Express community. Examples include cors, morgan, and helmet.</p>
<pre><code class="language-typescript">import express from "express";
import cors from "cors";

const app = express();
app.use(cors());
</code></pre>
<h4 id="heading-note-middleware-categories-in-express-are-not-mutually-exclusive">Note: Middleware categories in Express are not mutually exclusive</h4>
<p><strong>Example 1:</strong></p>
<pre><code class="language-javascript">import express from "express";

const app = express();
app.use(express.json());
</code></pre>
<p>The <code>express.json()</code> middleware in the snippet above is both:</p>
<ul>
<li><p>Application-level, because it is attached to the <code>app</code> instance</p>
</li>
<li><p>Built-in, because Express provides it</p>
</li>
</ul>
<p><strong>Example 2:</strong></p>
<pre><code class="language-javascript">import express from "express";
import cors from "cors";

const router = express.Router();
router.use(cors());
</code></pre>
<p>The <code>cors()</code> middleware in the snippet above is both:</p>
<ul>
<li><p>Router-level, because it is attached to the <code>router</code> instance</p>
</li>
<li><p>Third-party, because a package outside of Express provided it</p>
</li>
</ul>
<p>Are you asking what the <code>express.Router()</code> method in the example above is? Let's discuss it.</p>
<h2 id="heading-what-is-the-expressrouter-method">What Is the <code>express.Router()</code> Method?</h2>
<p>The <code>express.Router()</code> method creates a router object that groups related routes and middleware. You can attach this router to an Express application or another router using <code>app.use()</code> or <code>router.use()</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/61672d1653401f641ba159b4/1ae7a155-57df-42dd-ae99-940c92f3f1f3.jpg" alt="Why is Express Router a mini-app?" style="display: block;" width="1920" height="1920" loading="lazy">

<p>As illustrated in the image above, a router is often called a mini-app because it provides a complete middleware and routing system. It helps organize applications by allowing you to define related routes in one place and mount them onto the main Express application. For instance, suppose your Express application needs to handle the following categories of routes and middleware:</p>
<ul>
<li><p><strong>Index:</strong> For index-related routes and middleware.</p>
</li>
<li><p><strong>Books:</strong> For book-related routes and middleware.</p>
</li>
<li><p><strong>Videos:</strong> For video-related routes and middleware.</p>
</li>
</ul>
<p>In this situation, you can use the <code>express.Router()</code> method to group related routes and middleware. This keeps things organized and makes each group easier to manage and reuse.</p>
<p>While you can create all router and app instances in one file, developers usually place routers in separate files for better organization and maintainability. Let's follow this approach by creating separate files for each route and middleware category.</p>
<h3 id="heading-create-a-directory-for-the-routers">Create a Directory for the Routers</h3>
<p>Create a <code>routes</code> directory at your project's root to store all the app's routers.</p>
<pre><code class="language-console">mkdir routes
</code></pre>
<p><strong>Note:</strong> You can name this folder whatever you like, but <code>routes</code> is a common name since it holds the files that define your app's routes (URL endpoints). The <code>express.Router()</code> method is just a tool that Express provides for managing those routes.</p>
<h3 id="heading-create-the-route-files">Create the Route Files</h3>
<p>A route file is a module where you define related routes and export an Express router object. Each file usually represents a resource or feature, such as users, books, or authentication.</p>
<p>Create the route files in the <code>routes</code> directory.</p>
<pre><code class="language-console">touch routes/index.js routes/books.js routes/videos.js
</code></pre>
<h3 id="heading-create-a-mini-app-for-the-book-related-routes-and-middleware">Create a Mini-App for the Book-Related Routes and Middleware</h3>
<p>Open the <code>books.js</code> file and use an <code>express.Router()</code> instance to group all the book-related routes and middleware.</p>
<p><code>routes/books.js</code></p>
<pre><code class="language-javascript">// Import the Router function from Express
import { Router } from "express";

// Create a new Express router instance
const bookRouter = Router();

// Define middleware specific to this router instance
bookRouter.use((req, res, next) =&gt; {
  console.log("Book route accessed at: ", Date.now());
  next();
});

// Define routes specific to this router instance:

bookRouter.get("/", (req, res) =&gt; {
  res.send("&lt;h1&gt;List of All Books&lt;/h1&gt;");
});

bookRouter.get("/:id", (req, res) =&gt; {
  res.type("text/plain").send(`Details for book ${req.params.id}`);
});

bookRouter.get("/:id/draft", (req, res) =&gt; {
  // Escape the ID before inserting it into HTML text
  const bookId = req.params.id
    .replaceAll("&amp;", "&amp;amp;")
    .replaceAll("&lt;", "&amp;lt;")
    .replaceAll("&gt;", "&amp;gt;");
  res.send(`&lt;h1&gt;New Draft for Book ${bookId}&lt;/h1&gt;`);
});

export { bookRouter };
</code></pre>
<p>Here's what the code above does:</p>
<ul>
<li><p>Creates a <code>bookRouter</code> instance to access Express APIs, such as <code>.get()</code>, <code>.post()</code>, <code>.use()</code>, and <code>.route()</code>.</p>
</li>
<li><p>Uses Express's <code>router.use()</code> method to mount middleware onto the <code>bookRouter</code> instance.</p>
</li>
<li><p>Uses Express's <code>router.get()</code> method to define how the server handles HTTP GET requests.</p>
</li>
<li><p>Uses Express's <code>res.send()</code> method to send an HTTP response and automatically set the appropriate headers based on the data type provided. The <code>/:id</code> handler explicitly sets the response type to plain text, while the <code>/:id/draft</code> handler escapes the ID before inserting it into HTML so it is displayed as text.</p>
</li>
<li><p>Uses the <code>export</code> statement to make the router object available for import wherever it is needed in the application.</p>
</li>
</ul>
<h3 id="heading-time-to-practice-with-routers">Time to Practice with Routers</h3>
<p>This is your moment to try out what you've learned about Express routers.</p>
<p>For this exercise, create mini-apps for the project's index and video-related routes and middleware. Export them as <code>indexRouter</code> from <code>routes/index.js</code> and <code>videoRouter</code> from <code>routes/videos.js</code> to match the imports in the next section.</p>
<p>Take a moment to try this on your own before moving on. If you need help, review the <code>books.js</code> file. Practicing will help you understand better.</p>
<p>After you finish the exercise, move on to setting up the main Express app.</p>
<h3 id="heading-update-the-main-express-application">Update the Main Express Application</h3>
<p>Open your project's <code>server.js</code> file and attach the routers to the main Express application.</p>
<p><code>server.js</code></p>
<pre><code class="language-javascript">// Add the Express module
import express from "express";

// Import the routers from their separate files
import { indexRouter } from "./routes/index.js";
import { bookRouter } from "./routes/books.js";
import { videoRouter } from "./routes/videos.js";

// Create a new Express application instance
const app = express();

// Specify the host and port to receive requests
const host = "localhost";
const port = 3000;

// Mount the routers to specific paths:

// Mount indexRouter at /
app.use("/", indexRouter);

// Mount bookRouter at /books and its subpaths
app.use("/books", bookRouter);

// Mount videoRouter at /videos and its subpaths
app.use("/videos", videoRouter);

// Run the server on the specified port and host
app.listen(port, host, () =&gt; {
  console.log(`Server running live at http://${host}:
${port}!`);
});
</code></pre>
<p>Here's what the code above does:</p>
<ul>
<li><p>Creates an Express application instance to access Express APIs, such as routers and middleware, which simplifies HTTP request handling in Node.js.</p>
</li>
<li><p>Uses Express's <code>app.use()</code> method to add the mini-apps to the main Express application.</p>
</li>
<li><p>Uses Express's <code>app.listen()</code> method to start the Node.js HTTP server on a specific port and execute a callback when the server begins listening for client requests.</p>
</li>
</ul>
<p><strong>Note:</strong> The <code>app.use()</code> method's path serves as the router's base path or mount path. Routes defined in the router are matched relative to this mount path. For example:</p>
<ul>
<li><p><code>bookRouter</code>'s <code>/</code> route matches requests to <code>/books</code>.</p>
</li>
<li><p><code>bookRouter</code>'s <code>/:id</code> route matches requests to <code>/books/:id</code>.</p>
</li>
<li><p><code>bookRouter</code>'s <code>/:id/draft</code> route matches requests to <code>/books/:id/draft</code>.</p>
</li>
</ul>
<h3 id="heading-run-your-expressjs-application">Run your Express.js Application</h3>
<p>Once you've set up the routers, run your app by starting the server file with Node.js.</p>
<pre><code class="language-console">node --watch server.js
</code></pre>
<p>Then, check your app running live at the specified endpoints. For example, <code>http://localhost:3000/books</code>.</p>
<p><strong>Tip:</strong> Stop the running process using <code>Ctrl + C</code> on Windows, macOS, or Linux.</p>
<h3 id="heading-important-things-to-know-about-express-routers">Important Things to Know About Express Routers</h3>
<p>Here are some important things to remember when using Express routers.</p>
<h4 id="heading-routers-do-not-inherit-parent-route-parameters-by-default">Routers do not inherit parent route parameters by default</h4>
<p>By default, Express doesn't pass parameters from a router's mount path to routes inside that router.</p>
<p><strong>Example:</strong></p>
<pre><code class="language-javascript">// Add the Express module
import express from "express";

// Create a new Express application instance
const app = express();

// Create a new Express router instance
const bookRouter = express.Router();

// Define a sub-route
bookRouter.get("/books/:bookId", (req, res) =&gt; {
  const topic = req.params.topic; // undefined by default
  const bookId = req.params.bookId;
  res.json({ topic, bookId });
});

// Define a parent route
app.use("/codesweetly/:topic", bookRouter);

// Run the server on the specified port and host
const server = app.listen(3000, "127.0.0.1", () =&gt; {
  const { address, port } = server.address();
  console.log(`Server running live at http://${address}:
${port}!`);
});
</code></pre>
<p>If you run the web server in the example above and enter the following URL in the browser:</p>
<pre><code class="language-txt">http://127.0.0.1:3000/codesweetly/CSS/books/91827
</code></pre>
<p>The <code>req.params.topic</code> parameter will be <code>undefined</code> because sub-routes can't access the parent route's path parameter by default. So, the server will only send a <code>{"bookId":"91827"}</code> response to the client.</p>
<p>To let a child route access its parent's path parameter, set <code>mergeParams</code> to <code>true</code> when you create the router.</p>
<p><strong>Example:</strong></p>
<pre><code class="language-javascript">// Add the Express module
import express from "express";

// Create a new Express application instance
const app = express();

// Create a new Express router instance
const bookRouter = express.Router({ mergeParams: true });

// Define a sub-route
bookRouter.get("/books/:bookId", (req, res) =&gt; {
  const topic = req.params.topic; // "CSS" for the URL below
  const bookId = req.params.bookId;
  res.json({ topic, bookId });
});

// Define a parent route
app.use("/codesweetly/:topic", bookRouter);

// Run the server on the specified port and host
const server = app.listen(3000, "127.0.0.1", () =&gt; {
  const { address, port } = server.address();
  console.log(`Server running live at http://${address}:
${port}!`);
});
</code></pre>
<p>If you run the web server in the example above and enter the following URL in your browser:</p>
<pre><code class="language-txt">http://127.0.0.1:3000/codesweetly/CSS/books/91827
</code></pre>
<p>The <code>req.params.topic</code> parameter will be <code>CSS</code> because <code>mergeParams: true</code> lets the sub-route access the parent's path parameter. So, the server will send a <code>{"topic":"CSS","bookId":"91827"}</code> response to the client.</p>
<p><strong>Tip:</strong></p>
<ul>
<li><p>The line <code>const { address, port } = server.address()</code> uses <a href="https://codesweetly.com/destructuring-object">object destructuring</a> to get the <code>address</code> and <code>port</code> values from the <code>server.address()</code> object.</p>
</li>
<li><p>The <code>app.listen()</code> method returns a native Node.js <code>http.Server</code> instance, allowing access to underlying server APIs such as <code>.address()</code>, <code>.on()</code>, and <code>.maxHeadersCount</code>.</p>
</li>
<li><p><code>127.0.0.1</code> is an IPv4 loopback address. <code>localhost</code> may resolve to <code>127.0.0.1</code> or the IPv6 loopback address <code>::1</code>. Use <code>http://127.0.0.1:3000</code> for these examples because the server explicitly listens on that IPv4 address.</p>
</li>
</ul>
<h4 id="heading-routers-can-be-mounted-on-other-routers">Routers can be mounted on other routers</h4>
<p>You can attach one Express router to another.</p>
<p><strong>Example:</strong></p>
<pre><code class="language-javascript">// Add the Express module
import express from "express";

// Create a new Express application instance
const app = express();

// Create two Express router instances
const htmlRouter = express.Router();
const bookRouter = express.Router();

// Define a sub-route
bookRouter.get("/:bookId", (req, res) =&gt; {
  res
    .type("text/plain")
    .send(`Details for the HTML book with ID ${req.params.bookId}`);
});

// Mount bookRouter on htmlRouter's /books path
htmlRouter.use("/books", bookRouter);

// Mount htmlRouter at /html and its subpaths
app.use("/html", htmlRouter);

// Run the server on the specified port and host
const server = app.listen(3000, "127.0.0.1", () =&gt; {
  const { address, port } = server.address();
  console.log(`Server running live at http://${address}:
${port}!`);
});
</code></pre>
<p>The <code>bookRouter</code>'s <code>GET /:bookId</code> route matches requests to <code>GET /html/books/:bookId</code> because, in this example, the book router is attached to <code>htmlRouter</code>.</p>
<p>Therefore, if you run the web server in the example above and enter the following URL in the browser:</p>
<pre><code class="language-txt">http://127.0.0.1:3000/html/books/91827
</code></pre>
<p>The server will send a "Details for the HTML book with ID 91827" response to the client.</p>
<p>Now that you know what a router is, let's discuss how to use views to define the content and structure of the webpage your Express.js app returns to users.</p>
<h2 id="heading-what-is-a-view-in-expressjs">What Is a View in Express.js?</h2>
<p>A view is a template or document that defines the content and structure of a webpage returned to users by an Express.js application. It can be a static HTML file or a dynamic template rendered into HTML at <a href="https://codesweetly.com/web-tech-terms-r/#runtime">runtime</a> by a template engine.</p>
<p>In this guide, we'll work with two types of views:</p>
<ul>
<li><p>Static views</p>
</li>
<li><p>Dynamic views</p>
</li>
</ul>
<h3 id="heading-what-are-static-views-in-expressjs">What Are Static Views in Express.js?</h3>
<p>Static views contain the final HTML that the server sends to the browser as-is. For example, let's configure your Express.js application to serve four static views.</p>
<h4 id="heading-create-static-views">Create static views</h4>
<p>Create <code>index.html</code>, <code>about.html</code>, <code>contact.html</code>, and <code>404.html</code> files in your project's root directory.</p>
<pre><code class="language-console">touch index.html about.html contact.html 404.html
</code></pre>
<p>Open each view and add the following HTML content:</p>
<h5 id="heading-indexhtml-homepage">index.html (homepage)</h5>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;Static Views in Express.js | CodeSweetly Tutorial&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;Welcome to the Static View Guide&lt;/h1&gt;
    &lt;p&gt;Site's pages:&lt;/p&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;a href="/about"&gt;About&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="/contact"&gt;Contact&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h5 id="heading-abouthtml-about-page">about.html (about page)</h5>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;About Us | Static Views in Express.js&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;About Us&lt;/h1&gt;
    &lt;p&gt;
      Lorem ipsum dolor sit amet consectetur adipisicing elit. Libero soluta
      voluptas reprehenderit minus veniam! Corrupti a esse quidem nostrum harum,
      explicabo tempore tempora aut, sint et voluptatem magni ea vel?
    &lt;/p&gt;
    &lt;div&gt;&lt;a href="/"&gt;Return to the home page&lt;/a&gt;&lt;/div&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h5 id="heading-contacthtml-contact-page">contact.html (contact page)</h5>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;Contact | Static Views in Express.js&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;Contact Us&lt;/h1&gt;
    &lt;ul&gt;
      &lt;li&gt;&lt;a href="https://codesweetly.com"&gt;Website&lt;/a&gt;&lt;/li&gt;
      &lt;li&gt;&lt;a href="https://x.com/oluwatobiss"&gt;X (Twitter)&lt;/a&gt;&lt;/li&gt;
    &lt;/ul&gt;
    &lt;div&gt;&lt;a href="/"&gt;Return to the home page&lt;/a&gt;&lt;/div&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h5 id="heading-404html-error-page">404.html (error page)</h5>
<pre><code class="language-html">&lt;!doctype html&gt;
&lt;html lang="en"&gt;
  &lt;head&gt;
    &lt;meta charset="UTF-8" /&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0" /&gt;
    &lt;title&gt;404 - Page not found | Static Views in Express.js&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;Page not found&lt;/h1&gt;
    &lt;div&gt;Sorry, we couldn't find that page&lt;/div&gt;
    &lt;div&gt;&lt;a href="/"&gt;Go back to the home page&lt;/a&gt;&lt;/div&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Next, configure your project to handle browser connections.</p>
<h4 id="heading-configure-the-projects-web-server-to-serve-static-views">Configure the project's web server to serve static views</h4>
<p>Open the project's <code>server.js</code> module and configure the web server to serve the appropriate static HTML view in response to requests.</p>
<pre><code class="language-javascript">// Add the required modules
import express from "express";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

// Create a new Express application instance
const app = express();

// Specify the hostname and port to receive requests
const host = "localhost";
const port = 3000;

// Get the directory name of the current file's path
const __dirname = dirname(fileURLToPath(import.meta.url));

// Use the index.html static view as a response to 'GET /' requests
app.get("/", (req, res) =&gt; res.sendFile(join(__dirname, "index.html")));

// Use the about.html static view as a response to 'GET /about' requests
app.get("/about", (req, res) =&gt; {
  res.sendFile(join(__dirname, "about.html"));
});

// Use the contact.html static view as a response to 'GET /contact' requests
app.get("/contact", (req, res) =&gt; {
  res.sendFile(join(__dirname, "contact.html"));
});

// Use the 404.html static view as a response to unmatched requests
app.use((req, res) =&gt; {
  res.status(404).sendFile(join(__dirname, "404.html"));
});

// Run the server on the specified port and hostname
app.listen(port, host, () =&gt; {
  console.log(`Server running live at http://${host}:
${port}`);
});
</code></pre>
<p>Here are the main things the snippet above does:</p>
<ul>
<li><p>Create an Express application instance to access Express APIs, such as routers and middleware, which simplify HTTP request handling in Node.js.</p>
</li>
<li><p>Get the directory name of the current file's path.</p>
<ul>
<li><p><code>dirname()</code> returns a path's directory name.</p>
</li>
<li><p><code>fileURLToPath()</code> converts a URL to a valid path string.</p>
</li>
<li><p><code>import.meta.url</code> returns the absolute URL of the current file.</p>
</li>
</ul>
</li>
<li><p>Create routes to receive and respond to clients' requests.</p>
<ul>
<li><p><code>app</code> is an Express application instance.</p>
</li>
<li><p>The <code>app.get()</code> method defines how the server handles HTTP GET requests to the specified path (the method's first argument).</p>
</li>
<li><p>The <code>res.sendFile()</code> method sends a file from the server to the client.</p>
</li>
<li><p><code>join()</code> joins multiple path segments into a single, normalized path string using the current operating system’s path separator.</p>
</li>
<li><p>The <code>app.use()</code> method lets you set up handler functions that Express runs for all HTTP request methods and paths. If you specify a path argument, <code>app.use()</code> treats it as a prefix for matching routes. For example, <code>/book</code> matches <code>/book</code>, <code>/book/dashboard</code>, and <code>/book/dashboard/author/202605</code>.</p>
</li>
<li><p><code>res.status(404)</code> sets the HTTP status code to 404 for a request that reaches this final handler without receiving a response.</p>
</li>
</ul>
</li>
<li><p>Use Express's <code>app.listen()</code> method to start the Node.js HTTP server on a specific port and execute a callback when the server begins listening for client requests.</p>
</li>
</ul>
<h4 id="heading-run-your-expressjs-application">Run your Express.js application</h4>
<p>Once you've set up the server, run it with Node.js.</p>
<pre><code class="language-console">node --watch server.js
</code></pre>
<p>Afterward, check your app running live at <code>http://localhost:3000</code>.</p>
<p><strong>Tip:</strong> Stop the running process using <code>Ctrl + C</code> on Windows, macOS, or Linux.</p>
<p>To keep things organized, put all your static views in a <code>public</code> directory. This is a common practice.</p>
<h4 id="heading-create-a-directory-for-static-views">Create a directory for static views</h4>
<p>Create a <code>public</code> directory at your project's root to store all static views.</p>
<pre><code class="language-console">mkdir public
</code></pre>
<p><strong>Note:</strong> You can name this folder whatever you like, but <code>public</code> is a common choice to indicate that its files (HTML, CSS, client-side JavaScript, images) are intended to be served to clients. The folder's name alone does not make its contents accessible. The server must be configured to serve them. Don't put sensitive information in it.</p>
<h4 id="heading-move-all-static-views-into-the-public-folder">Move all static views into the <code>public</code> folder</h4>
<pre><code class="language-console">mv index.html about.html contact.html 404.html public/
</code></pre>
<h4 id="heading-update-the-server-to-retrieve-static-views-from-the-public-folder">Update the server to retrieve static views from the <code>public</code> folder</h4>
<p><code>server.js</code></p>
<pre><code class="language-javascript">import express from "express";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const app = express();
const host = "localhost";
const port = 3000;
const __dirname = dirname(fileURLToPath(import.meta.url));

// Serve static assets (HTML, CSS, images) from the 'public' folder
app.use(express.static(join(__dirname, "public")));

// Use the index.html static view as a response to 'GET /' requests
app.get("/", (req, res) =&gt; {
  res.sendFile(join(__dirname, "/public/index.html"));
});

// Use the about.html static view as a response to 'GET /about' requests
app.get("/about", (req, res) =&gt; {
  res.sendFile(join(__dirname, "/public/about.html"));
});

// Use the contact.html static view as a response to 'GET /contact' requests
app.get("/contact", (req, res) =&gt; {
  res.sendFile(join(__dirname, "/public/contact.html"));
});

// Use the 404.html static view as a response to unmatched requests
app.use((req, res) =&gt; {
  res.status(404).sendFile(join(__dirname, "/public/404.html"));
});

// Run the server on the specified port and hostname:
app.listen(port, host, () =&gt; {
  console.log(`Server running live at http://${host}:
${port}`);
});
</code></pre>
<p><strong>Note</strong> The <code>app.use(express.static(join(__dirname, 'public')))</code> line tells Express to check the public folder first for static asset requests. If it finds the file there, it immediately serves it to the browser and stops the request-response cycle.</p>
<p><strong>Example:</strong></p>
<ul>
<li><p>If a user requests <code>localhost:3000/about.html</code>, <code>express.static</code> checks the <code>public</code> folder for the <code>about.html</code> file. If found, Express automatically sends it without running the route handlers that follow the static middleware.</p>
</li>
<li><p>If a user asks for <code>localhost:3000/about</code>, <code>express.static</code> checks the <code>public</code> folder for an <code>about</code> file (no extension). If it finds an <code>about</code> directory instead, it redirects to <code>/about/</code>, where it looks for <code>index.html</code> by default. With the files created in this guide, neither an <code>about</code> file nor an <code>about</code> directory exists, so Express uses the <code>GET /about</code> custom route to handle the request.</p>
</li>
</ul>
<p>Let's now discuss dynamic views.</p>
<h3 id="heading-what-are-dynamic-views-in-expressjs">What Are Dynamic Views in Express.js?</h3>
<p>Dynamic views contain placeholders that template engines, such as EJS, replace with data at runtime.</p>
<p>Express.js supports template engines such as EJS, Pug, and Handlebars. Let's use EJS as an example to discuss how dynamic views work.</p>
<h4 id="heading-what-is-ejs">What is EJS?</h4>
<p>EJS (<a href="https://ejs.co">Embedded JavaScript templating</a>) is a templating language that lets you create dynamic HTML views by embedding JavaScript code within HTML templates using template tags.</p>
<p>When the server renders a template, the EJS engine executes the embedded JavaScript and generates the final HTML sent to the client.</p>
<p>For instance, consider the following code. Install EJS with <code>npm install ejs</code> before running this example:</p>
<pre><code class="language-javascript">// Add the Express and EJS modules
import express from "express";
import ejs from "ejs";

// Create a new Express application instance
const app = express();

// Specify the host and port to receive requests
const host = "localhost";
const port = 8000;

// Define the data object
const friendsArray = ["Sarah", "Abraham", "Mary"];
const replacementObject = { friends: friendsArray };

// Use backticks to define a template string with embedded JavaScript
const templateString = `
  &lt;h1&gt;Hello, &lt;%= friends.join(", "); %&gt;!&lt;/h1&gt;
  &lt;p&gt;Welcome to CodeSweetly.&lt;/p&gt;
`;

// Compile and render the template string to HTML (the rendered view)
const html = ejs.render(templateString, replacementObject);

// Use the dynamic view as a response to 'GET /' requests
app.get("/", (req, res) =&gt; res.send(html));

// Run the server on the specified port and hostname
app.listen(port, host, () =&gt; {
  console.log(`Server running live at http://${host}:
${port}!`);
});
</code></pre>
<p>The above snippet tells Express to send the rendered view to the client when users request the <code>/</code> path.</p>
<p>At runtime, EJS evaluates <code>friends.join(", ")</code> using the array passed as <code>friends</code> and inserts the result, <code>Sarah, Abraham, Mary</code>, into the HTML. Here, rendering happens once when the script starts. Each request receives that same rendered HTML.</p>
<h4 id="heading-what-are-ejs-template-tags">What are EJS template tags?</h4>
<p>EJS template tags are special tags written using the <code>&lt;% ... %&gt;</code> syntax. They allow JavaScript code and values to be embedded into EJS view templates.</p>
<p>When EJS renders the template, scriptlet tags execute JavaScript, output tags insert values, and comment tags are ignored.</p>
<p>For example, the <code>&lt;%= %&gt;</code> tags below allow you to embed a JavaScript <code>friend</code> variable into the view template.</p>
<pre><code class="language-ejs">&lt;h1&gt;About &lt;%= friend %&gt;, my special pal&lt;/h1&gt;
</code></pre>
<p><strong>Note:</strong> Output tags such as <code>&lt;%= %&gt;</code> must contain a valid JavaScript expression. Scriptlet tags can contain JavaScript statements and parts of control-flow structures, while comment tags contain comments.</p>
<p><strong>Example:</strong></p>
<ul>
<li><p><code>&lt;%= &lt;p&gt;I love CodeSweetly&lt;/p&gt; %&gt;</code> will throw an error because <code>&lt;p&gt;I love CodeSweetly&lt;/p&gt;</code> is HTML, not JavaScript.</p>
</li>
<li><p><code>&lt;%= "&lt;p&gt;I love CodeSweetly&lt;/p&gt;" %&gt;</code> is valid because <code>"&lt;p&gt;I love CodeSweetly&lt;/p&gt;"</code> is embedded as a JavaScript string data type.</p>
</li>
</ul>
<h4 id="heading-types-of-ejs-template-tags">Types of EJS template tags</h4>
<p>The nine EJS tag forms covered here are as follows:</p>
<p><strong>Tip:</strong> Wrap JavaScript code in EJS tags to distinguish it from the surrounding template content. A single pair of tags can contain multiple lines of JavaScript.</p>
<h5 id="heading-closing-tag-gt">Closing tag (<code>%&gt;</code>)</h5>
<p>Use the closing tag to end an EJS tag. It has no special behavior on its own.</p>
<h5 id="heading-scriptlet-tag-lt">Scriptlet tag (<code>&lt;%</code>)</h5>
<p>Use the scriptlet tag to run JavaScript code without outputting anything into the generated HTML. This tag is commonly used for control flow, such as <code>if</code>, <code>for</code>, and <code>forEach</code> logic.</p>
<pre><code class="language-ejs">&lt;ul&gt;
  &lt;% friends.forEach(function () { %&gt;
  &lt;li&gt;My friend&lt;/li&gt;
  &lt;% }) %&gt;
&lt;/ul&gt;
</code></pre>
<p>The snippet above wraps <code>&lt;% %&gt;</code> around the control-flow syntax to indicate to EJS that the JavaScript code is only for logic and should not be rendered to the generated HTML.</p>
<h5 id="heading-escaped-output-tag-lt">Escaped output tag (<code>&lt;%=</code>)</h5>
<p>Use the escaped output tag to evaluate JavaScript expressions and output the result into the generated HTML while escaping HTML characters. This tag helps prevent <a href="https://www.utep.edu/information-resources/iso/security-awareness/technical-security-resources/what-is-html-injection.html">HTML injection</a>.</p>
<pre><code class="language-ejs">&lt;%= 100 + 200 %&gt;
</code></pre>
<p>The above snippet wraps <code>&lt;%= %&gt;</code> around the arithmetic expression to tell EJS to render the code's value into the generated HTML.</p>
<p><strong>Note:</strong> If the expression evaluates to a string containing HTML, <code>&lt;%= %&gt;</code> escapes the HTML syntax so the browser displays it as text. For example, <code>&lt;%= "&lt;h1&gt;About CodeSweetly&lt;/h1&gt;" %&gt;</code> will render <code>&lt;h1&gt;About CodeSweetly&lt;/h1&gt;</code> as text, not an actual <code>&lt;h1&gt;</code> element.</p>
<h5 id="heading-unescaped-output-tag-lt">Unescaped output tag (<code>&lt;%-</code>)</h5>
<p>Use the unescaped tag to evaluate JavaScript expressions and output the result into the generated HTML without escaping HTML characters.</p>
<pre><code class="language-ejs">&lt;%- "&lt;h1&gt;About CodeSweetly&lt;/h1&gt;" %&gt;
</code></pre>
<p>The snippet above wraps <code>&lt;%- %&gt;</code> around a string containing an <code>&lt;h1&gt;</code> element so the browser renders it as HTML. Use unescaped output only for trusted HTML.</p>
<h5 id="heading-literal-opening-tag-lt">Literal opening tag (<code>&lt;%%</code>)</h5>
<p>Use the literal tag to output a literal <code>&lt;%</code> sequence instead of treating it as an EJS tag. This is useful when you want to show EJS syntax in the generated output.</p>
<pre><code class="language-ejs">&lt;%% "&lt;p&gt;&lt;strong&gt;Name:&lt;/strong&gt; &lt;em&gt;Oluwatobi&lt;/em&gt;&lt;/p&gt;" %&gt;
</code></pre>
<p>The snippet above outputs a literal <code>&lt;%</code> followed by the remaining text, without evaluating it as JavaScript. The generated HTML source is:</p>
<pre><code class="language-plaintext">&lt;% "
&lt;p&gt;&lt;strong&gt;Name:&lt;/strong&gt; &lt;em&gt;Oluwatobi&lt;/em&gt;&lt;/p&gt;
" %&gt;
</code></pre>
<p>The HTML tags remain unescaped, so this shows the generated source, not how the browser displays the page.</p>
<h5 id="heading-newline-trimmed-ending-tag-gt">Newline-trimmed ending tag (<code>-%&gt;</code>)</h5>
<p>Use the newline-trimmed ending tag to remove the newline immediately after the closing tag.</p>
<pre><code class="language-ejs">&lt;ul&gt;
  &lt;% friends.forEach(function (friend) { %&gt;
  &lt;li&gt;&lt;%= friend -%&gt;&lt;/li&gt;
  &lt;% }) %&gt;
&lt;/ul&gt;
</code></pre>
<p>The snippet above wraps <code>&lt;%= -%&gt;</code> around the <code>friend</code> variable to tell EJS to trim any newline following the closing EJS tag.</p>
<p><strong>Tip:</strong> The newline-trimmed ending tag is also called the newline slurp tag.</p>
<h5 id="heading-whitespace-slurp-opening-tag-lt">Whitespace slurp opening tag (<code>&lt;%_</code>)</h5>
<p>Use the whitespace slurping tag to remove spaces and tabs immediately before the opening EJS tag on the same line.</p>
<pre><code class="language-ejs">&lt;ul&gt;
  &lt;%_ friends.forEach(function (friend) { %&gt;
  &lt;li&gt;&lt;%= friend %&gt;&lt;/li&gt;
  &lt;%_ }) %&gt;
&lt;/ul&gt;
</code></pre>
<p>The snippet above wraps <code>&lt;%_ %&gt;</code> around the control-flow syntax to tell EJS to trim the indentation before the template tags.</p>
<h5 id="heading-whitespace-slurp-closing-tag-gt">Whitespace slurp closing tag (<code>_%&gt;</code>)</h5>
<p>Use the whitespace slurping ending tag to remove spaces and tabs immediately after the closing tag, followed by one newline if present.</p>
<pre><code class="language-ejs">&lt;ul&gt;
  &lt;% friends.forEach(function (friend) { _%&gt;
  &lt;li&gt;&lt;%= friend %&gt;&lt;/li&gt;
  &lt;% }) _%&gt;
&lt;/ul&gt;
</code></pre>
<p>The snippet above wraps <code>&lt;% _%&gt;</code> around the control-flow syntax to tell EJS to trim the newline after each closing tag. The indentation on the following line remains.</p>
<h5 id="heading-comment-tag-lt">Comment tag (<code>&lt;%#</code>)</h5>
<p>Use the comment tag to add EJS comments that are ignored during rendering and do not appear in the generated HTML.</p>
<pre><code class="language-ejs">&lt;ul&gt;
  &lt;%# Loop through the friends array %&gt;
  &lt;% friends.forEach(function (friend) { _%&gt;
  &lt;li&gt;&lt;%= friend %&gt;&lt;/li&gt;
  &lt;% }) _%&gt;
&lt;/ul&gt;
</code></pre>
<p>The snippet above wraps the comment in <code>&lt;%# %&gt;</code>.</p>
<p><strong>Tip:</strong> EJS allows you to combine some tags. For example, the following combinations are possible:</p>
<ul>
<li><p><code>&lt;%_ _%&gt;</code>: Combine the whitespace slurping scriptlet and ending tag to remove adjacent spaces and tabs on the same line, plus one following newline if present.</p>
</li>
<li><p><code>&lt;% -%&gt;</code>: Combine the control-flow with the newline slurp.</p>
</li>
<li><p><code>&lt;%= -%&gt;</code>: Combine the escaped tag with the newline slurp.</p>
</li>
</ul>
<h4 id="heading-how-to-use-ejs-in-an-express-project">How to use EJS in an Express project</h4>
<p>The following sections will guide you through the process of using EJS in your Express.js project.</p>
<h5 id="heading-install-ejs">Install EJS</h5>
<p>First, install EJS in your Express project.</p>
<pre><code class="language-console">npm install ejs@6.0.1
</code></pre>
<p>Next, open your project's <code>server.js</code> module and configure the web server to serve the appropriate dynamically generated HTML view in response to requests.</p>
<p><code>server.js</code></p>
<pre><code class="language-javascript">// Add the Express and EJS modules
import express from "express";
import ejs from "ejs";

// Create a new Express application instance
const app = express();

// Specify the host and port to receive requests
const host = "localhost";
const port = 8000;

// Define the data object
const friendsData = {
  bestFriend: "Sarah",
  codingFriend: "Abraham",
  dreamFriend: "Mary",
};
const replacementObject = { friends: friendsData };

// Use backticks to define a template string with embedded JavaScript
const templateString = `
&lt;html&gt;
  &lt;body&gt;
    &lt;h1&gt;List of Friends&lt;/h1&gt;
    &lt;ul&gt;
    &lt;%# Loop through the friends object %&gt;
    &lt;% for (const eachFriend in friends) { -%&gt;
      &lt;li&gt;&lt;%= friends[eachFriend] %&gt; is my &lt;%= eachFriend %&gt;&lt;/li&gt;
    &lt;% } -%&gt;
    &lt;/ul&gt;
  &lt;/body&gt;
&lt;/html&gt;
`;

// Compile and render the template string to HTML (the rendered view)
const htmlData = ejs.render(templateString, replacementObject);

// Use the dynamic view as a response to 'GET /' requests
app.get("/", (req, res) =&gt; res.send(htmlData));

// Run the server on the specified port and hostname
app.listen(port, host, () =&gt; {
  console.log(`Server running live at http://${host}:
${port}!`);
});
</code></pre>
<p>Here are the main things the snippet above does:</p>
<ul>
<li><p>Create an Express application instance to access Express APIs, such as routers and middleware, which simplify HTTP request handling in Node.js.</p>
</li>
<li><p>Render the template string once when the script starts to generate the HTML that the server sends to each client.</p>
</li>
<li><p>Create routes to receive and respond to clients' requests.</p>
</li>
<li><p>Use Express's <code>app.listen()</code> method to start the Node.js HTTP server on a specific port and execute a callback when the server begins listening for client requests.</p>
</li>
</ul>
<h5 id="heading-run-the-express-app">Run the Express app</h5>
<p>After setting up the server, run it with Node.js.</p>
<pre><code class="language-console">node --watch server.js
</code></pre>
<p>Afterward, check your app running live at <code>http://localhost:8000</code>.</p>
<p><strong>Tip:</strong> Stop the running process using <code>Ctrl + C</code> on Windows, macOS, or Linux.</p>
<p>To keep things organized, put all your view templates in a <code>views</code> directory. This is a common practice.</p>
<h5 id="heading-create-a-directory-for-view-templates">Create a directory for view templates</h5>
<p>Create a <code>views</code> directory at your project's root to store all view templates.</p>
<pre><code class="language-console">mkdir views
</code></pre>
<h5 id="heading-create-an-index-view-template">Create an index view template</h5>
<pre><code class="language-console">touch views/index.ejs
</code></pre>
<p><strong>Tip:</strong> EJS template files use the <code>.ejs</code> extension.</p>
<p>Open the file and move the HTML template from <code>server.js</code> into it.</p>
<p><code>views/index.ejs</code></p>
<pre><code class="language-ejs">&lt;html&gt;
  &lt;body&gt;
    &lt;h1&gt;List of Friends&lt;/h1&gt;
    &lt;ul&gt;
      &lt;%# Loop through the friends object %&gt;
      &lt;% for (const eachFriend in friends) { -%&gt;
      &lt;li&gt;&lt;%= friends[eachFriend] %&gt; is my &lt;%= eachFriend %&gt;&lt;/li&gt;
      &lt;% } -%&gt;
    &lt;/ul&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h5 id="heading-update-the-server-to-retrieve-view-templates-from-the-views-folder">Update the server to retrieve view templates from the <code>views</code> folder</h5>
<p><code>server.js</code></p>
<pre><code class="language-javascript">import express from "express";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const app = express();
const host = "localhost";
const port = 8000;
const __dirname = dirname(fileURLToPath(import.meta.url));

// Specify the app's views directory
app.set("views", join(__dirname, "views"));

// Specify the app's view engine
app.set("view engine", "ejs");

// Define the data object
const friendsData = {
  bestFriend: "Sarah",
  codingFriend: "Abraham",
  dreamFriend: "Mary",
};
const replacementObject = { friends: friendsData };

// Compile and render the index view template to HTML and use it as a response to 'GET /' requests
app.get("/", (req, res) =&gt; {
  res.render("index", replacementObject);
});

// Run the server on the specified port and hostname
app.listen(port, host, () =&gt; {
  console.log(`Server running live at http://${host}:
${port}`);
});
</code></pre>
<p>The snippet above uses Express's <code>render()</code> method to generate HTML from the <code>index.ejs</code> template file.</p>
<p><strong>Note:</strong></p>
<ul>
<li>If you omit the <code>app.set("view engine", "ejs")</code> line, then the <code>render()</code> method's view template argument must include a file extension like this:</li>
</ul>
<pre><code class="language-javascript">app.get("/", (req, res) =&gt; {
  res.render("index.ejs", replacementObject);
});
</code></pre>
<ul>
<li><p>Express has no default template engine. Express also supports engines such as Pug (formerly called Jade), EJS, Mustache, and Handlebars.</p>
</li>
<li><p><code>app.set()</code> is the method for adding a key-value pair to the application's settings. You can retrieve the setting's value with <code>app.get()</code> as follows:</p>
</li>
</ul>
<pre><code class="language-javascript">app.set("my name", "Oluwatobi");

const bio = app.get("my name");

console.log(bio); // Outputs: "Oluwatobi"
</code></pre>
<h4 id="heading-how-to-create-reusable-ejs-templates-partials">How to create reusable EJS templates (partials)</h4>
<p>EJS provides the <code>include()</code> method for including (nesting) one view template into another.</p>
<p><strong>Tip:</strong> In EJS, partials are reusable templates.</p>
<h5 id="heading-syntax-of-the-include-method">Syntax of the include() method</h5>
<p>The <code>include()</code> method accepts two arguments. Here's the syntax:</p>
<pre><code class="language-ejs">&lt;%- include("path/to/partial", data) %&gt;
</code></pre>
<ul>
<li><p><code>"path/to/partial"</code>: (required) The path to the reusable template, relative to the current file (the parent template). For example, if the current file is at <code>"./views/index.ejs"</code> and the partial is at <code>"./views/partials/footer.ejs"</code>, the <code>include()</code> path argument would be <code>"partials/footer"</code>.</p>
</li>
<li><p><code>data</code>: (optional) An object containing the properties to pass to the partial.</p>
</li>
</ul>
<h5 id="heading-how-to-include-a-partial-in-a-view-template">How to include a partial in a view template</h5>
<p>Consider the following server file:</p>
<p><code>server.js</code></p>
<pre><code class="language-javascript">import express from "express";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const app = express();
const host = "localhost";
const port = 8080;
const __dirname = dirname(fileURLToPath(import.meta.url));

app.set("views", join(__dirname, "views"));
app.set("view engine", "ejs");

const friendsData = {
  bestFriend: "Sarah",
  codingFriend: "Abraham",
  dreamFriend: "Mary",
};
const replacementObject = { friends: friendsData };

app.get("/", (req, res) =&gt; {
  res.render("index", replacementObject);
});

app.listen(port, host, () =&gt; {
  console.log(`Server running live at http://${host}:
${port}`);
});
</code></pre>
<p>The snippet above will render an <code>index</code> template when users send a GET request to the <code>"/"</code> path. Below is the <code>index.ejs</code> template file.</p>
<p><code>views/index.ejs</code></p>
<pre><code class="language-ejs">&lt;html&gt;
  &lt;body&gt;
    &lt;h1&gt;List of Friends&lt;/h1&gt;
    &lt;ul&gt;
    &lt;%# Loop through the friends object %&gt;
    &lt;% for (const eachFriend in friends) { -%&gt;
      &lt;li&gt;&lt;%= friends[eachFriend] %&gt; is my &lt;%= eachFriend %&gt;&lt;/li&gt;
    &lt;% } -%&gt;
    &lt;/ul&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>If you need the <code>&lt;li&gt;</code> element to be reusable in multiple templates, extract it into a separate file and use the <code>include()</code> method to add it to any template as needed. Here's how:</p>
<h5 id="heading-create-a-partials-directory">Create a partials directory</h5>
<p>Create a <code>partials</code> directory in your <code>views</code> folder to store all reusable view templates.</p>
<pre><code class="language-console">mkdir views/partials
</code></pre>
<h5 id="heading-create-a-partial-view-template">Create a partial view template</h5>
<p>Create a partial view template for the <code>&lt;li&gt;</code> element.</p>
<pre><code class="language-console">touch views/partials/friendLi.ejs
</code></pre>
<p>Open the file and add the <code>&lt;li&gt;</code> element.</p>
<p><code>views/partials/friendLi.ejs</code></p>
<pre><code class="language-ejs">&lt;li&gt;&lt;%= friend %&gt; is my &lt;%= friendType %&gt;&lt;/li&gt;
</code></pre>
<h5 id="heading-update-the-main-view-template">Update the main view template</h5>
<p>Open the <code>index.ejs</code> file and use the <code>include()</code> method to add the <code>friendLi.ejs</code> partial view template.</p>
<p><code>views/index.ejs</code></p>
<pre><code class="language-ejs">&lt;html&gt;
  &lt;body&gt;
    &lt;h1&gt;List of Friends&lt;/h1&gt;
    &lt;ul&gt;
    &lt;%# Loop through the friends object %&gt;
    &lt;% for (const eachFriend in friends) { -%&gt;
      &lt;%- include("partials/friendLi", { friend: friends[eachFriend], friendType: eachFriend }) %&gt;
    &lt;% } -%&gt;
    &lt;/ul&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Using <code>include()</code> to add the <code>&lt;li&gt;</code> element makes it reusable in multiple templates, rather than restricting it to the <code>index.ejs</code> view template.</p>
<h5 id="heading-run-your-server-from-the-root-directory">Run your server from the root directory</h5>
<pre><code class="language-console">node --watch server.js
</code></pre>
<p>Then, check your app running live at <code>http://localhost:8080</code>.</p>
<p><strong>Note:</strong> Although the steps above demonstrate EJS partials with a simple <code>&lt;li&gt;</code> element, you can use the same concept to reuse your application's navbar, footer, and other components across multiple view templates.</p>
<h2 id="heading-overview">Overview</h2>
<p>In this handbook, we explored the core concepts you need to start building applications with Node.js and Express.js. We discussed how Node.js works, its module support, and how to create HTTP servers. We also explored how Express.js simplifies server-side development through routes, routers, and views.</p>
<p>Whether you’re considering a small personal project or a full-stack application for a larger user base, you now have a solid starting point for building with Node.js and Express.js.</p>
<p>Thanks for reading!</p>
<h3 id="heading-dive-deeper-into-nodejs-and-expressjs">Dive Deeper into Node.js and Express.js</h3>
<p>This handbook has given you a peek inside my <a href="https://www.amazon.com/dp/B0HK23L2Y1?tag=codesweetly00-20">Node.js and Express.js Simplified book</a>.</p>
<p>Whether you’re learning backend development for the first time, expanding your JavaScript skills beyond frontend development, or looking for a practical introduction to Node.js and Express.js, the book will help you build on what you’ve learned here and develop the skills to create, test, and deploy complete applications.</p>
<p>You’ll dive deeper into Node.js and Express.js through practical explanations and hands-on examples covering topics such as file management, file uploads, forms, sessions, frontend-to-backend communication, testing, TypeScript, and deployment.</p>
<p><a href="https://www.amazon.com/dp/B0HK23L2Y1?tag=codesweetly00-20"><img src="https://cdn.hashnode.com/uploads/covers/61672d1653401f641ba159b4/bcad7d15-53d7-4ba7-8974-b424c50c01a0.jpg" alt="Build with Node.js &amp; Express.js: A practical, beginner-friendly guide to building, testing, and deploying web applications" style="display: block;" width="970" height="250" loading="lazy"></a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Chatbot with Gemini and Vercel Serverless Functions 🚀  ]]>
                </title>
                <description>
                    <![CDATA[ A couple of months back, I built a chatbot application using React, Node.js, and Vercel Serverless Functions, which I used in my web app, buildcv.makeadifference.app. In this tutorial, I'll walk you t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-chatbot-with-gemini-and-vercel-serverless-functions/</link>
                <guid isPermaLink="false">6a9f3c3bbf19ac7f324e6191</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ aitools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Vercel ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gemini ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chatbot development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Johnson Samuel ]]>
                </dc:creator>
                <pubDate>Mon, 07 Sep 2026 22:35:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4062fae6-db2f-47b8-add8-30d68a223f5b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A couple of months back, I built a chatbot application using React, Node.js, and Vercel Serverless Functions, which I used in my web app, <a href="http://buildcv.makeadifference.app">buildcv.makeadifference.app</a>.</p>
<p>In this tutorial, I'll walk you through exactly how I built it, from the backend serverless function that talks to Google's Gemini API, to the React chat widget that displays the AI's response as it's being generated.</p>
<p>By the end of this tutorial, you'll understand:</p>
<ol>
<li><p>How to set up a Vercel Serverless function that calls Gemini and streams its response back to the browser in small text chunks.</p>
</li>
<li><p>Why that approach makes a chatbot feel faster and more responsive than waiting for the entire reply to come back at once.</p>
</li>
<li><p>How to build a React component that reads that streaming response and updates the chat window in real time as new text arrives.</p>
</li>
</ol>
<p>We'll also test the endpoint with curl before wiring up the UI, and we'll cover how to actually deploy the whole thing to Vercel.</p>
<p>All of this revolves around a technique called <strong>plain-text chunk streaming</strong>. Rather than waiting for the AI to generate its entire response and then sending it all back to the browser at once, the serverless function streams the response as a series of small text chunks, writing each one to the browser as soon as it's ready.</p>
<p>This is what gives the chatbot that responsive, "typing" feel you've probably seen in modern AI chat interfaces, instead of a long pause followed by the full answer appearing all at once.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-architecture-overview">🧩 Architecture Overview</a></p>
</li>
<li><p><a href="#heading-prerequisites">✅ Prerequisites</a></p>
</li>
<li><p><a href="#heading-api-contract">📜 API Contract</a></p>
</li>
<li><p><a href="#heading-backend-the-vercel-serverless-function">⚙️ Backend (the Vercel Serverless Function)</a></p>
</li>
<li><p><a href="#heading-testing-the-endpoint-before-wiring-up-the-ui">🧪 Testing the Endpoint Before Wiring Up the UI</a></p>
</li>
<li><p><a href="#heading-frontend-reading-the-response-stream">🎨 Frontend: Reading the Response Stream</a></p>
</li>
<li><p><a href="#heading-deploying">🚀 Deploying</a></p>
</li>
<li><p><a href="#heading-a-note-on-cors-for-embedded-widgets">⚠️ A Note on CORS for Embedded Widgets</a></p>
</li>
<li><p><a href="#heading-conclusion">🎉 Conclusion</a></p>
</li>
</ul>
<h2 id="heading-architecture-overview">🧩 Architecture Overview</h2>
<p>The application is built from two main pieces. The UI component is a React chat widget, where the user types a message and the AI's response gets rendered back to them as it streams in. The backend is a Vercel serverless function (for example, <code>api/chat</code>) that validates the incoming request, calls Gemini, and streams the output back to the browser.</p>
<p>You can read more about Vercel's serverless functions in <a href="https://vercel.com/docs/functions">their official documentation</a>.</p>
<p>Here's how the whole flow works, from start to finish: the browser sends a request to the Vercel function, the Vercel function calls the Gemini API, and Gemini's response is streamed back to the browser chunk by chunk.</p>
<p>In practice, this means the backend sends the response to the frontend in small pieces as they become available, rather than making the user wait for the entire response to be generated before they see anything at all.</p>
<h2 id="heading-prerequisites">✅ Prerequisites</h2>
<p>Before you get started, make sure you have the following in place:</p>
<ul>
<li><p>Node.js installed locally (Node 18 or later)</p>
</li>
<li><p>A Gemini API key 🔑 from <a href="https://aistudio.google.com/">Google AI Studio</a></p>
</li>
<li><p>A <a href="https://vercel.com/">Vercel account</a> and the <a href="https://vercel.com/docs/cli">Vercel CLI</a></p>
</li>
<li><p>The <code>@google/genai</code> package installed in your function's project, which you can add by running <code>npm install @google/genai</code></p>
</li>
</ul>
<p>You'll also want to store your key in an environment variable called <code>process.env.GOOGLE_API_KEY</code> for local development, and add that same variable in your Vercel project's dashboard (under Settings and then Environment Variables) before deploying, so the API key gets picked up correctly once the application is hosted.</p>
<h2 id="heading-api-contract">📜 API Contract</h2>
<p>Before diving into the code, it's worth taking a moment to understand the "contract" between the frontend and backend – in other words, what the frontend is expected to send, and what the backend expects to receive in return.</p>
<p>When a user sends a message in the chat widget, the frontend makes a POST request to <code>/api/chat</code> with a JSON body containing two things: the message the user just typed, and the résumé data that's already loaded into the app (since this particular chatbot is acting as a résumé coach). The backend then uses both pieces of information together to generate a response that's actually relevant to that user's résumé, rather than a generic reply.</p>
<p><strong>Request:</strong></p>
<pre><code class="language-plaintext">POST /api/chat
{
  "message": "How can I improve my resume summary?",
  "resume": { "name": "...", "experience": [...], "skills": [...] }
}
</code></pre>
<h2 id="heading-backend-the-vercel-serverless-function">⚙️ Backend (the Vercel Serverless Function)</h2>
<p>This is the heart of the application: a single Vercel serverless function that receives the chat request, validates it, passes it along to Gemini, and streams the AI's response back to the browser as it's generated.</p>
<p>Below is the full handler, and I'll walk through what each part of it is doing once you've had a look at the whole thing.</p>
<pre><code class="language-jsx">const { GoogleGenAI } = require("@google/genai");
const ai = new GoogleGenAI({
    apiKey: process.env.GOOGLE_API_KEY,  // GOOGLE_API_KEY can be configured in Vercel
});
const MAX_TEXT_LENGTH = 2000;
const MAX_ARRAY_LENGTH = 50;

function sanitizeString(input = "") {
    // sanitize your input string here
}

function sanitizeObject(obj = {}) {
    // sanitize your resume object
}

const allowCors = fn =&gt; async (req, res) =&gt; {

    res.setHeader('Access-Control-Allow-Origin', '&lt;&lt;your web app url goes here&gt;&gt;');
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

    // ✅ Handle preflight request
    if (req.method === 'OPTIONS') {
        return res.status(200).end();
    }
    // another option
    res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS, POST, PUT, DELETE')
    res.setHeader(
        'Access-Control-Allow-Headers',
        'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version'
    )
    if (req.method === 'OPTIONS') {
        res.status(200).end()
        return
    }
    return await fn(req, res)
};

const handler = async (req, res) =&gt; {

    if (req.url === '/api/chat' &amp;&amp; req.method === 'POST') {

        const { message, resume } = req.body || {};

        const safeMessage = sanitizeString(message);
        const safeResume = sanitizeObject(resume);

        if (!safeMessage) {
            return res.status(400).json({ error: "Invalid message" });
        }

        if (!safeMessage || !safeResume) {
            return res.status(400).json({ error: 'Missing message or resume data' });
        }

        try {
            const stream = await ai.models.generateContentStream({
                model: "&lt;&lt;GEMINI MODEL GOES HERE&gt;&gt;",
                contents: `
            You are a professional Resume Coach AI.

            - Always respond clearly, politely, and professionally.
            - &lt;&lt;Add descriptive instruction for the prompt based on your requirement&gt;&gt;
            - Resume data:
${JSON.stringify(safeResume, null, 2)}

User question:
${safeMessage}
`,
            });
            res.setHeader("Content-Type", "text/plain; charset=utf-8");
            res.setHeader("Cache-Control", "no-cache");

            for await (const chunk of stream) {
                const text = chunk.text;
                if (text) {
                    res.write(text);
                }
            }

            res.end();
        } catch (error) {
            console.error('Gemini Error:', error);
            res.status(500).json({ error: 'AI request failed' });
        }
    }

    // I have added a health check for testing the handler
    if (req.url === '/api/chat?type=healthcheck' &amp;&amp; req.method === 'GET') {
        res.status(200).json({ message: 'Hello from the chat endpoint!' });
    }
}

module.exports = allowCors(handler)
</code></pre>
<p>Now let's break that down piece by piece:</p>
<ul>
<li><p><strong>Setting up the Gemini client:</strong> At the top of the file, we create a <code>GoogleGenAI</code> client using the API key stored in the <code>GOOGLE_API_KEY</code> environment variable. This client is what we'll use to actually talk to Gemini throughout the rest of the function.</p>
</li>
<li><p><strong>Sanitizing the input:</strong> Before we do anything with the incoming request, we run the message and résumé through <code>sanitizeString</code> and <code>sanitizeObject</code>. These functions strip out anything unexpected or oversized, so we're never passing untrusted user input straight through to the AI without a check first.</p>
</li>
<li><p><strong>Handling CORS:</strong> The <code>allowCors</code> wrapper function sits around our handler and takes care of Cross-Origin Resource Sharing. Since the chat widget might be embedded on a different domain than the Vercel function itself, we need to explicitly allow requests from that domain and handle the <code>OPTIONS</code> preflight request that browsers automatically send before the real <code>POST</code> request goes through.</p>
</li>
<li><p><strong>Validating the request:</strong> Inside the handler itself, we check that both <code>message</code> and <code>resume</code> made it through sanitization successfully. If either one is missing or invalid, we return a 400 error right away instead of wasting a call to Gemini on a request we already know is bad.</p>
</li>
<li><p><strong>Calling Gemini and streaming the response:</strong> This is the key part of the whole tutorial. Instead of calling a regular "generate content" method and waiting for the full response to come back, we call <code>generateContentStream</code>, which returns an async iterable. We loop over that stream with a <code>for await...of</code> loop, and every time a new chunk of text comes in, we immediately write it to the response with <code>res.write(text)</code>. This is exactly what lets the browser start receiving text before Gemini has even finished generating the full answer.</p>
</li>
<li><p><strong>Error handling and the health check:</strong> If anything goes wrong while talking to Gemini, we catch the error, log it, and return a 500 response so the frontend knows something failed. There's also a simple <code>GET</code> health check endpoint you can hit to confirm the function is up and running before you start testing the actual chat flow.</p>
</li>
</ul>
<h2 id="heading-testing-the-endpoint-before-wiring-up-the-ui">🧪 Testing the Endpoint Before Wiring Up the UI</h2>
<p>Before you start building out the frontend, it's a good idea to confirm that the serverless function is actually streaming chunks the way you expect. You can do that with a simple curl request.</p>
<pre><code class="language-bash">curl -N -X POST "https://YOUR_APP.vercel.app/api/chat?type=chat" \
  -H "Content-Type: application/json" \
  -d '{"message":"Give me 3 resume summary tips","resume":{"name":"Test"}}'
</code></pre>
<p>The <code>-N</code> flag disables curl's output buffering, so you'll see the text appear incrementally in your terminal rather than all at once. That's your confirmation that the streaming is working end to end, before you've written a single line of frontend code.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6093d844d1bbc47b91a66bb6/03f59037-0d2b-49eb-9bb2-87716698d5d2.png" alt="Streaming working" style="display: block;" width="2868" height="492" loading="lazy">

<h2 id="heading-frontend-reading-the-response-stream">🎨 Frontend: Reading the Response Stream</h2>
<p>Here's a screenshot of the chat widget UI I built in React:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6093d844d1bbc47b91a66bb6/3c726767-0fbc-4784-9ce0-24f9d94ae549.png" alt="3c726767-0fbc-4784-9ce0-24f9d94ae549" style="display: block;" width="636" height="826" loading="lazy">

<p>We won't walk through building that UI from scratch here. The layout, styling, and message list are really up to you and your own design preferences.</p>
<p>What we will cover is the function that actually powers it: the code behind the send button, which takes what the user typed, sends it off to our serverless function, and reads back the streaming response so the widget can display the AI's reply as it arrives, piece by piece, the way you see it happening in the screenshot above.</p>
<p>Before jumping into that function, here's the shell it lives inside, so you can see where it fits in the component:</p>
<pre><code class="language-jsx">function ChatWidget({ resume }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  async function sendMessage() {
    // ...actual function is added below
  }

  return (
    &lt;div className="chat-widget"&gt;
      {/* message list, input box, and a send button that calls sendMessage() */}
    &lt;/div&gt;
  );
}
</code></pre>
<p>That's the shell the function lives in: state for the message list, the input box, a loading flag, and an error slot. <code>sendMessage</code> is what fires when the send button is clicked.</p>
<p>Here's the full function, <code>sendMessage</code>, which runs whenever the user types a prompt and clicks send. This is the piece of code that connects the UI shown above to the backend we just built.</p>
<pre><code class="language-jsx">async function sendMessage() {
    const messageText = input.trim();
    if (!messageText || loading) return;

    const userMessage = { role: "user", text: messageText };
    setMessages((message) =&gt; [...message, userMessage]);
    setInput("");
    setLoading(true);
    setError(null);

    try {
      const res = await fetch("&lt;&lt;YOUR VERCEL SERVERLESS FUNCTION URL GOES HERE&gt;&gt;/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message: messageText, resume })
      });

      if (!res.ok) {
        throw new Error(`Failed to get response: ${res.status}`);
      }

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

      let fullText = "";
      setMessages((message) =&gt; [...message, { role: "assistant", text: "" }]);

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

        const chunk = decoder.decode(value);
        fullText += chunk;

        setMessages((message) =&gt; {
          const updated = [...message];
          updated[updated.length - 1] = { role: "assistant", text: fullText };
          return updated;
        });
      }

    } catch (err) {
      setError("Failed to send message. Please try again.");
      setMessages((m) =&gt; [...m, {
        role: "assistant",
        text: "Sorry, I encountered an error. Please try again later."
      }]);
    } finally {
      setLoading(false);
    }
  }
</code></pre>
<p>Here's what's happening step by step:</p>
<p>First, when the user clicks send, we grab their typed message and immediately add it to the <code>messages</code> array so it shows up in the chat right away.</p>
<p>Then we send a <code>POST</code> request to our <code>/api/chat</code> endpoint with the message and the résumé data. Once we get a response back, we call <code>res.body.getReader()</code> to get a <code>ReadableStreamDefaultReader</code> on the response body, and we use a <code>TextDecoder</code> to convert each raw chunk of bytes into readable text.</p>
<p>From there, we loop, reading one chunk at a time and appending it to a growing <code>fullText</code> string, and on every iteration we update the last message in the chat with that text.</p>
<p>That loop is exactly what creates the "typing" effect you see in the screenshot above: the assistant's message in the chat widget visibly grows word by word as new chunks arrive, instead of popping in all at once. This pattern is a bit different from the typical <code>fetch().then(res =&gt; res.json())</code> call you're probably used to, so it's worth pausing on if you haven't streamed a fetch response before.</p>
<h2 id="heading-deploying">🚀 Deploying</h2>
<p>Once you've confirmed that the function and UI both work locally, deploying comes down to three steps:</p>
<ol>
<li><p><strong>Set your environment variable in Vercel.</strong> In your project's dashboard, go to Settings and then Environment Variables and add <code>GOOGLE_API_KEY</code> with the same value you used locally.</p>
</li>
<li><p><strong>Update your CORS origin and fetch URL.</strong> In the backend, point <code>allowCors</code>'s <code>Access-Control-Allow-Origin</code> header at your real deployed domain. In the frontend, point the <code>fetch</code> call at that same deployed function URL instead of localhost.</p>
</li>
<li><p><strong>Deploy</strong>, using whichever of the two options below fits your workflow.</p>
</li>
</ol>
<h4 id="heading-option-a-deploy-from-the-cli">Option A: Deploy from the CLI</h4>
<pre><code class="language-bash">npm install -g vercel   # if you haven't already
vercel login
vercel --prod
</code></pre>
<p><code>vercel login</code> authenticates your machine, and <code>vercel --prod</code> builds and ships straight to production from your project directory. This is a good fit for one-off deploys, or when you want full control over exactly when a deploy happens.</p>
<h4 id="heading-option-b-deploy-via-github-integration">Option B: Deploy via GitHub integration</h4>
<ol>
<li><p>Push your project to a GitHub repository (if it isn't already).</p>
</li>
<li><p>In the Vercel dashboard, click <strong>Add New</strong> and then <strong>Project</strong>, then select your repository.</p>
</li>
<li><p>Vercel auto-detects your framework settings. Confirm them and click <strong>Deploy</strong>.</p>
</li>
<li><p>From then on, every push to your main branch triggers an automatic redeploy.</p>
</li>
</ol>
<p>I've always preferred this option and it has been more convenient if you're actively iterating on the project, since you never have to remember to run a deploy command yourself.</p>
<p>Once the deploy finishes, Vercel will give you a live URL for your app. Test it with the same curl command from earlier, swapping in your production URL, to confirm that streaming is working correctly in production before you consider yourself done.</p>
<h2 id="heading-a-note-on-cors-for-embedded-widgets">⚠️ A Note on CORS for Embedded Widgets</h2>
<p>If you're planning to embed this chat widget on a website that lives on a different domain than your Vercel app (for example, embedding it as a widget on a marketing site while the function itself is deployed elsewhere), you'll run into CORS restrictions by default. Browsers block requests to a different origin unless the server explicitly allows them.</p>
<p>To handle this correctly, your serverless function needs to respond to the <code>OPTIONS</code> preflight request that the browser automatically sends before the real <code>POST</code> request, and it needs to set the <code>Access-Control-Allow-Origin</code> header to match the domain your widget is actually running on. That's exactly what the <code>allowCors</code> wrapper in the backend code above is doing for you.</p>
<h2 id="heading-conclusion">🎉 Conclusion</h2>
<p>Plain-text chunk streaming is what makes a chatbot feel responsive on Vercel. Because the user sees the response appear gradually, they aren't stuck waiting for the entire answer to be generated before anything shows up on screen.</p>
<p>If you'd like to keep building on this, here are a few natural next steps to consider: adding rate limiting to prevent abuse of the endpoint, adding conversation persistence with something like KV, Redis, or Postgres so chat history isn't lost on refresh, and adding a stop button to the widget so users can cancel a response that's already in the middle of generating.</p>
<p>If you build something with this approach, I'd love to hear about it! What are you planning to build with Gemini and Vercel?</p>
<p>Hope you have a blessed week! 😇</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Scholarship Research MCP Server with Node.js, Express, and MongoDB ]]>
                </title>
                <description>
                    <![CDATA[ Scholarship hunting is a research job, not a single search box. You filter awards by field, GPA, citizenship, and deadline. You keep a shortlist. You write notes about essays and recommenders. Then yo ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-scholarship-research-mcp-server-with-node-js-express-and-mongodb/</link>
                <guid isPermaLink="false">6a9b2db334fe985abf2f91a9</guid>
                
                    <category>
                        <![CDATA[ mcp ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Model Context Protocol ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Express.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ MongoDB ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chinedu Otutu ]]>
                </dc:creator>
                <pubDate>Fri, 04 Sep 2026 20:44:35 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bc3b99da-15f4-4366-aede-4a2b567724aa.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Scholarship hunting is a research job, not a single search box. You filter awards by field, GPA, citizenship, and deadline. You keep a shortlist. You write notes about essays and recommenders. Then you come back a week later and try to remember why you saved a particular program.</p>
<p>An AI assistant can help with that workflow, but only if it can query a real catalog and persist what you already decided. Chat history isn't a database. A hallucinated deadline is worse than no deadline at all.</p>
<p>The <a href="https://modelcontextprotocol.io/docs/learn/architecture">Model Context Protocol (MCP)</a> is the standard way to give AI apps that kind of access. In this tutorial, you'll build a scholarship research MCP server with Node.js, Express, and MongoDB.</p>
<p>When you finish, Cursor, Claude Desktop, or any other MCP host will be able to search awards, match them to a student profile, bookmark a shortlist, and store research notes. The model stays the reasoning layer while your server owns the data.</p>
<p>You'll build:</p>
<ul>
<li><p>A MongoDB catalog of scholarships, plus saved-list and notes collections</p>
</li>
<li><p>Nine MCP tools for search, matching, deadlines, comparison, and research tracking</p>
</li>
<li><p>Resources so a client can read the catalog without calling a tool</p>
</li>
<li><p>Prompts that turn a student profile into a research plan</p>
</li>
<li><p>An Express app that serves MCP over Streamable HTTP</p>
</li>
</ul>
<p>The sample catalog in this project is a teaching dataset. Amounts, dates, and eligibility rules are simplified. Always confirm details on the official application page before applying.</p>
<h2 id="heading-what-you-need">What You Need</h2>
<p>You should be comfortable with JavaScript and basic Express routing. You don't need prior MCP experience.</p>
<p>Install:</p>
<ul>
<li><p><a href="https://nodejs.org/">Node.js 20</a> or later</p>
</li>
<li><p><a href="https://www.mongodb.com/docs/manual/installation/">MongoDB</a> running locally, or a free <a href="https://www.mongodb.com/atlas">MongoDB Atlas</a> cluster</p>
</li>
<li><p>An MCP client if you want to try the last section. <a href="https://cursor.com/">Cursor</a> and <a href="https://claude.ai/download">Claude Desktop</a> both work.</p>
</li>
</ul>
<p>Docker is enough for MongoDB:</p>
<pre><code class="language-bash">docker run -d --name mongo -p 27017:27017 mongo:7
</code></pre>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-you-need">What You Need</a></p>
</li>
<li><p><a href="#heading-what-is-the-model-context-protocol">What Is the Model Context Protocol?</a></p>
</li>
<li><p><a href="#heading-why-a-scholarship-research-server">Why a Scholarship Research Server?</a></p>
</li>
<li><p><a href="#heading-how-the-architecture-fits-together">How the Architecture Fits Together</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-how-to-connect-mongodb">How to Connect MongoDB</a></p>
</li>
<li><p><a href="#heading-how-to-model-scholarship-data">How to Model Scholarship Data</a></p>
</li>
<li><p><a href="#heading-how-to-write-the-scholarship-service">How to Write the Scholarship Service</a></p>
</li>
<li><p><a href="#heading-how-to-register-mcp-tools">How to Register MCP Tools</a></p>
</li>
<li><p><a href="#heading-how-to-expose-resources-and-prompts">How to Expose Resources and Prompts</a></p>
</li>
<li><p><a href="#heading-how-to-serve-mcp-over-express">How to Serve MCP Over Express</a></p>
</li>
<li><p><a href="#heading-how-to-seed-the-catalog">How to Seed the Catalog</a></p>
</li>
<li><p><a href="#heading-how-to-test-the-server">How to Test the Server</a></p>
</li>
<li><p><a href="#heading-how-to-connect-cursor-and-claude-desktop">How to Connect Cursor and Claude Desktop</a></p>
</li>
<li><p><a href="#heading-how-a-research-session-runs">How a Research Session Runs</a></p>
</li>
<li><p><a href="#heading-how-the-matching-logic-works">How the Matching Logic Works</a></p>
</li>
<li><p><a href="#heading-what-you-can-build-next">What You Can Build Next</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-the-model-context-protocol">What Is the Model Context Protocol?</h2>
<p>MCP is an open protocol that lets an AI application talk to external tools and data sources through a shared contract. Anthropic introduced it in 2024, and it's now maintained as an open standard.</p>
<p>Anthropic described MCP as "a USB-C port for AI applications": one connector, many hosts. (Source: <a href="https://www.anthropic.com/news/model-context-protocol">Introducing the Model Context Protocol</a>) Instead of writing one integration for Cursor, another for Claude Desktop, and a third for a custom agent, you implement the protocol once.</p>
<p>The <a href="https://modelcontextprotocol.io/docs/learn/architecture">official architecture overview</a> splits MCP into two layers:</p>
<ul>
<li><p>The <strong>data layer</strong> is JSON-RPC 2.0. Clients and servers exchange requests such as <code>tools/list</code> and <code>tools/call</code>.</p>
</li>
<li><p>The <strong>transport layer</strong> moves those messages. Local servers usually use stdio. Remote or long-running servers use Streamable HTTP.</p>
</li>
</ul>
<p>This tutorial uses Streamable HTTP, because Express is already an HTTP server and you want the catalog available to any client on your machine.</p>
<h3 id="heading-hosts-clients-and-servers">Hosts, Clients, and Servers</h3>
<p>Three roles show up in every MCP setup:</p>
<ul>
<li><p>The <strong>host</strong> is the AI app. Cursor and Claude Desktop are hosts.</p>
</li>
<li><p>The <strong>client</strong> lives inside the host. The host creates one client per connected server.</p>
</li>
<li><p>The <strong>server</strong> is your program. It advertises tools, resources, and prompts, then handles calls.</p>
</li>
</ul>
<p>Your scholarship app is the server. You never talk to the model SDK directly. The host does that.</p>
<h3 id="heading-tools-resources-and-prompts">Tools, Resources, and Prompts</h3>
<p>MCP servers expose three primitives. You'll use all three.</p>
<p><strong>Tools</strong> are actions. The model decides to call them, the way it might call a function in a regular tool-calling API. Search, save, and compare belong here.</p>
<p><strong>Resources</strong> are data the host can read and attach as context. A catalog URI and a per-scholarship URI belong here. The model doesn't have to "take an action" to see them.</p>
<p><strong>Prompts</strong> are named templates. People usually invoke them from a slash command or a menu. A "research plan" prompt belongs here, because it's a workflow you want to start on purpose.</p>
<p>That split matters. If you put everything in tools, the model has to guess when to look things up. Resources and prompts give the host better knobs.</p>
<h2 id="heading-why-a-scholarship-research-server">Why a Scholarship Research Server?</h2>
<p>A weather MCP demo is a single API call. Scholarship research is closer to a real product:</p>
<ul>
<li><p>The catalog must be queryable. Keyword search, GPA filters, and deadline windows all live in the database.</p>
</li>
<li><p>The workflow must persist. A saved shortlist and research notes should survive a new chat.</p>
</li>
<li><p>Eligibility is logic, not prose. A GPA minimum is a number. First-generation-only is a boolean. Put those checks in code so the model can't invent a match.</p>
</li>
<li><p>The output must be inspectable. Students should be able to open the official URL and verify every claim.</p>
</li>
</ul>
<p>MongoDB fits this well. Each scholarship is a document with nested arrays for fields of study, citizenship, and requirements. Saved items and notes are separate collections with references back to the catalog.</p>
<p>You could wrap a public API instead of storing documents. That's a good follow-up. Starting with your own catalog keeps the tutorial self-contained and makes the MCP contract obvious.</p>
<h2 id="heading-how-the-architecture-fits-together">How the Architecture Fits Together</h2>
<p>The finished project looks like this:</p>
<pre><code class="language-text">MCP host (Cursor or Claude Desktop)
        |
        |  Streamable HTTP  POST /mcp
        v
Express app  (createMcpExpressApp)
        |
        |  createMcpHandler factory
        v
McpServer  tools / resources / prompts
        |
        v
Scholarship service
        |
        v
MongoDB  scholarships, savedScholarships, researchnotes
</code></pre>
<p>A few design choices are worth calling out before you write code.</p>
<p>The MCP handler is <strong>stateless</strong>. The SDK runs your server factory once per HTTP request. That's the recommended v2 pattern for Streamable HTTP. Don't keep tool state on the <code>McpServer</code> instance. Keep it in MongoDB.</p>
<p>The database connection is <strong>process-wide</strong>. Connecting on every request would be slow and pointless. You connect once at startup and close over that pool from the tools.</p>
<p>The HTTP surface is small on purpose. <code>/health</code> is for you. <code>/mcp</code> is for the protocol. You don't need a REST API in front of the same data unless you want one later.</p>
<h2 id="heading-how-to-set-up-the-project">How to Set Up the Project</h2>
<p>Create a folder and initialize a Node.js project. ESM is required because the MCP SDK is ESM-first.</p>
<pre><code class="language-bash">mkdir scholarship-research-mcp-server
cd scholarship-research-mcp-server
npm init -y
</code></pre>
<p>Open <code>package.json</code> and set <code>"type": "module"</code>. Then install the SDK, Express, Mongoose, Zod, and dotenv:</p>
<pre><code class="language-bash">npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express mongoose dotenv zod
</code></pre>
<p>The SDK split into packages in v2:</p>
<ul>
<li><p><code>@modelcontextprotocol/server</code> is the <code>McpServer</code> class and <code>createMcpHandler</code></p>
</li>
<li><p><code>@modelcontextprotocol/express</code> gives you <code>createMcpExpressApp</code>, including DNS rebinding protection</p>
</li>
<li><p><code>@modelcontextprotocol/node</code> adapts the web-standard handler to Node's <code>req</code>/<code>res</code></p>
</li>
</ul>
<p>Create a <code>.env</code> file:</p>
<pre><code class="language-bash">MONGODB_URI=mongodb://127.0.0.1:27017/scholarship_research
PORT=3000
HOST=127.0.0.1
</code></pre>
<p>Add a <code>.gitignore</code> that excludes <code>node_modules</code> and <code>.env</code>.</p>
<p>Your source layout can stay small:</p>
<pre><code class="language-text">src/
  index.js
  config.js
  db.js
  models/
  services/
  mcp/
  utils/
data/
  scholarships.json
scripts/
  seed.js
  smoke-test.js
</code></pre>
<p><code>src/config.js</code> reads environment variables with defaults:</p>
<pre><code class="language-javascript">export const config = {
  mongodbUri: process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/scholarship_research",
  port: Number(process.env.PORT ?? 3000),
  host: process.env.HOST ?? "127.0.0.1",
};
</code></pre>
<p>Keep configuration in one file. Tools shouldn't read <code>process.env</code> directly.</p>
<h2 id="heading-how-to-connect-mongodb">How to Connect MongoDB</h2>
<p>Mongoose 9 works cleanly with ESM. A short <code>src/db.js</code> is enough:</p>
<pre><code class="language-javascript">import mongoose from "mongoose";
import { config } from "./config.js";

export async function connectDatabase() {
  mongoose.set("strictQuery", true);
  await mongoose.connect(config.mongodbUri);
  return mongoose.connection;
}
</code></pre>
<p>Call this once in <code>src/index.js</code> before <code>app.listen</code>. If the connection fails, the process should exit. A running Express server with a dead database is harder to debug than a failed startup.</p>
<h2 id="heading-how-to-model-scholarship-data">How to Model Scholarship Data</h2>
<p>You need three collections.</p>
<h3 id="heading-scholarship">Scholarship</h3>
<p>This is the catalog. Store the fields a matching engine actually uses, not a blob of markdown.</p>
<pre><code class="language-javascript">import mongoose from "mongoose";

const scholarshipSchema = new mongoose.Schema(
  {
    title: { type: String, required: true, trim: true },
    provider: { type: String, required: true, trim: true },
    description: { type: String, required: true },
    amountMin: { type: Number, default: 0 },
    amountMax: { type: Number, default: 0 },
    currency: { type: String, default: "USD" },
    deadline: { type: Date, default: null },
    rolling: { type: Boolean, default: false },
    educationLevels: { type: [String], default: ["undergraduate"] },
    fieldsOfStudy: { type: [String], default: ["any"] },
    gpaMinimum: { type: Number, default: null },
    citizenship: { type: [String], default: ["any"] },
    countries: { type: [String], default: ["any"] },
    firstGenerationOnly: { type: Boolean, default: false },
    womenOnly: { type: Boolean, default: false },
    numberOfAwards: { type: Number, default: 1 },
    renewable: { type: Boolean, default: false },
    applicationUrl: { type: String, required: true },
    applicationRequirements: { type: [String], default: [] },
  },
  { timestamps: true },
);

scholarshipSchema.index({
  title: "text",
  provider: "text",
  description: "text",
  fieldsOfStudy: "text",
});
scholarshipSchema.index({ deadline: 1 });
scholarshipSchema.index({ amountMax: -1 });

export const Scholarship = mongoose.model("Scholarship", scholarshipSchema);
</code></pre>
<p>A few field choices are doing real work:</p>
<ul>
<li><p><code>fieldsOfStudy: ["any"]</code> means the award is field-open. The matcher treats <code>any</code> as a wildcard.</p>
</li>
<li><p><code>deadline: null</code> plus <code>rolling: true</code> covers programs that accept applications year-round.</p>
</li>
<li><p><code>applicationUrl</code> is mandatory. Every tool result should point back to a human-verifiable source.</p>
</li>
<li><p><code>gpaMinimum: null</code> means the provider didn't publish a cutoff. That's different from <code>0</code>.</p>
</li>
</ul>
<h3 id="heading-saved-scholarship">Saved Scholarship</h3>
<p>A research list is a join between a person and a catalog row.</p>
<pre><code class="language-javascript">const savedScholarshipSchema = new mongoose.Schema(
  {
    researcherId: { type: String, required: true, default: "default" },
    scholarship: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Scholarship",
      required: true,
    },
    status: {
      type: String,
      enum: ["saved", "applying", "submitted", "won", "rejected"],
      default: "saved",
    },
  },
  { timestamps: true },
);

savedScholarshipSchema.index({ researcherId: 1, scholarship: 1 }, { unique: true });
</code></pre>
<p>The unique index makes <code>save_scholarship</code> idempotent. Saving the same award twice updates the status instead of creating duplicates.</p>
<p><code>researcherId</code> is a plain string. For a tutorial, that's enough. In production you would take it from an auth token.</p>
<h3 id="heading-research-note">Research Note</h3>
<p>Notes are a separate collection so one scholarship can have many of them.</p>
<pre><code class="language-javascript">const researchNoteSchema = new mongoose.Schema(
  {
    researcherId: { type: String, required: true, default: "default" },
    scholarship: {
      type: mongoose.Schema.Types.ObjectId,
      ref: "Scholarship",
      required: true,
    },
    body: { type: String, required: true, trim: true },
  },
  { timestamps: true },
);
</code></pre>
<p>You now have a catalog, a shortlist, and a notebook. That's the whole product surface the MCP tools will wrap.</p>
<h2 id="heading-how-to-write-the-scholarship-service">How to Write the Scholarship Service</h2>
<p>Keep MongoDB queries out of the MCP layer. Tools should call a service, get plain objects back, and format text. That makes the same functions reusable from a seed script, a smoke test, or a future REST route.</p>
<h3 id="heading-search">Search</h3>
<p>Search is a filter builder. Each optional argument adds a clause. Open or rolling awards stay in the result set. Closed deadlines drop out.</p>
<pre><code class="language-javascript">const OPEN_DEADLINE_FILTER = {
  $or: [{ rolling: true }, { deadline: null }, { deadline: { $gte: new Date() } }],
};

export async function searchScholarships(filters) {
  const query = { ...OPEN_DEADLINE_FILTER };
  const and = [query];

  if (filters.keyword) {
    and.push({
      $or: [
        { title: { $regex: escapeRegex(filters.keyword), $options: "i" } },
        { provider: { $regex: escapeRegex(filters.keyword), $options: "i" } },
        { description: { $regex: escapeRegex(filters.keyword), $options: "i" } },
        { fieldsOfStudy: { $regex: escapeRegex(filters.keyword), $options: "i" } },
      ],
    });
  }

  if (filters.fieldOfStudy) {
    and.push({
      $or: [
        { fieldsOfStudy: { $regex: `^any$`, $options: "i" } },
        { fieldsOfStudy: { $regex: escapeRegex(filters.fieldOfStudy), $options: "i" } },
      ],
    });
  }

  // educationLevel, citizenship, country, minAmount, gpa, flags...

  const results = await Scholarship.find({ $and: and })
    .sort({ deadline: 1, amountMax: -1 })
    .limit(100)
    .lean();

  return rankByFieldMatch(results, filters.fieldOfStudy).slice(0, filters.limit ?? 10);
}
</code></pre>
<p>Two details are easy to skip and worth keeping.</p>
<p>Escape user input before you drop it into <code>$regex</code>. A keyword of <code>(</code> shouldn't become a broken regular expression.</p>
<p>When a student searches for <code>computer science</code>, field-open awards (<code>any</code>) are eligible, but the specific CS scholarships should appear first. Rank in memory after the query. MongoDB already did the eligibility filter. You're only adjusting display order.</p>
<h3 id="heading-match">Match</h3>
<p>Matching is not the same as search. Search is "find documents that look like this." Matching is "here is a student, score every open award."</p>
<p>The service loads open scholarships, then applies hard filters and a score:</p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>Effect</th>
</tr>
</thead>
<tbody><tr>
<td>Education level mismatch</td>
<td>Skip</td>
</tr>
<tr>
<td>GPA below the minimum</td>
<td>Skip</td>
</tr>
<tr>
<td>Citizenship mismatch</td>
<td>Skip</td>
</tr>
<tr>
<td>First-generation-only and student is not</td>
<td>Skip</td>
</tr>
<tr>
<td>Women-only and student is not</td>
<td>Skip</td>
</tr>
<tr>
<td>Education level match</td>
<td>+20</td>
</tr>
<tr>
<td>GPA eligible</td>
<td>+15</td>
</tr>
<tr>
<td>Citizenship eligible</td>
<td>+15</td>
</tr>
<tr>
<td>Field of study match</td>
<td>+30</td>
</tr>
<tr>
<td>Preferred country match</td>
<td>+10</td>
</tr>
<tr>
<td>Amount meets the student's minimum</td>
<td>+10</td>
</tr>
<tr>
<td>First-generation or women-only match</td>
<td>+10</td>
</tr>
</tbody></table>
<p>Hard filters prevent false hope. Soft scores rank the rest. Each result also returns a <code>reasons</code> array, so the model can explain the match instead of inventing one.</p>
<p>That last point is the whole reason to put matching on the server. If you only return raw documents, the model will sometimes "helpfully" include an award the student can't apply for. Returning <code>score</code> and <code>reasons</code> keeps the explanation grounded in code.</p>
<h3 id="heading-save-notes-deadlines-compare">Save, Notes, Deadlines, Compare</h3>
<p>The remaining functions are thin:</p>
<ul>
<li><p><code>saveScholarship</code> upserts by <code>(researcherId, scholarshipId)</code></p>
</li>
<li><p><code>addResearchNote</code> inserts a note after confirming the scholarship exists</p>
</li>
<li><p><code>getUpcomingDeadlines</code> queries <code>deadline</code> between now and <code>now + N days</code></p>
</li>
<li><p><code>compareScholarships</code> loads two or three documents and returns the same fields for each</p>
</li>
</ul>
<p>Validate MongoDB ids with <code>mongoose.Types.ObjectId.isValid</code> before you query. An LLM will occasionally pass a title where you asked for an id. Fail clearly. Don't throw a CastError into the MCP transport.</p>
<h2 id="heading-how-to-register-mcp-tools">How to Register MCP Tools</h2>
<p>Create <code>src/mcp/server.js</code> as a factory. The HTTP handler will call it on every request.</p>
<pre><code class="language-javascript">import { McpServer } from "@modelcontextprotocol/server";
import { registerPrompts } from "./prompts.js";
import { registerResources } from "./resources.js";
import { registerTools } from "./tools.js";

export function createScholarshipServer() {
  const server = new McpServer({
    name: "scholarship-research",
    version: "1.0.0",
  });

  registerTools(server);
  registerResources(server);
  registerPrompts(server);

  return server;
}
</code></pre>
<p>Keep this factory cheap. No database connections, no file reads, and no caches that belong at module scope. The <a href="https://ts.sdk.modelcontextprotocol.io/v2/serving/http.html">HTTP serving guide</a> is explicit about this: create connection pools once at startup, and close over them.</p>
<h3 id="heading-one-tool-fully">One Tool, Fully</h3>
<p><code>registerTool</code> takes a name, a config object, and a handler. The <code>inputSchema</code> is a Zod object. The SDK turns that schema into JSON Schema for <code>tools/list</code>, validates arguments before your handler runs, and infers types if you're on TypeScript.</p>
<pre><code class="language-javascript">import * as z from "zod/v4";

server.registerTool(
  "search_scholarships",
  {
    title: "Search scholarships",
    description:
      "Search the scholarship catalog by keyword, field of study, education level, citizenship, country, GPA, and award amount.",
    inputSchema: z.object({
      keyword: z.string().min(1).optional().describe("Free-text search across title, provider, description, and fields"),
      fieldOfStudy: z.string().optional().describe("For example computer science, public health, or engineering"),
      educationLevel: z.enum(["undergraduate", "graduate", "doctoral"]).optional(),
      citizenship: z.string().optional(),
      country: z.string().optional(),
      minAmount: z.number().nonnegative().optional(),
      gpa: z.number().min(0).max(4).optional(),
      firstGeneration: z.boolean().optional(),
      womenOnly: z.boolean().optional(),
      limit: z.number().int().min(1).max(25).optional(),
    }),
    annotations: { readOnlyHint: true, openWorldHint: false },
  },
  async (args) =&gt; {
    const results = await searchScholarships(args);
    return toolText(formatScholarshipList(results));
  },
);
</code></pre>
<p>Write descriptions as if the model is the only docs the tool will ever get. <code>.describe()</code> on a Zod field survives conversion to JSON Schema. That's how the host tells the model what <code>fieldOfStudy</code> means.</p>
<p><code>title</code> is the human label. <code>description</code> is the model-facing contract. They're not the same string.</p>
<h3 id="heading-annotations">Annotations</h3>
<p>Annotations don't change how the SDK runs the tool. Hosts use them to decide how cautious to be.</p>
<ul>
<li><p><code>readOnlyHint: true</code> for search, get, match, list, compare, and deadlines</p>
</li>
<li><p><code>readOnlyHint: false</code> for save and add-note</p>
</li>
<li><p><code>idempotentHint: true</code> on save, because of the unique index</p>
</li>
<li><p><code>openWorldHint: false</code> because this server talks to your database, not the open web</p>
</li>
</ul>
<p>A host can auto-approve a read-only search and ask the user before a write. That's worth five extra keys in the config.</p>
<h3 id="heading-return-shape">Return Shape</h3>
<p>Every tool returns MCP content blocks:</p>
<pre><code class="language-javascript">export function toolText(text, isError = false) {
  return {
    content: [{ type: "text", text }],
    isError,
  };
}
</code></pre>
<p>Return <code>isError: true</code> for domain failures such as "scholarship not found." Throw only for unexpected failures. The spec treats those differently. A validation error from Zod never reaches your handler. The SDK already converts it into an <code>isError</code> result.</p>
<p>Format lists for a person who is skimming a chat transcript. Include the MongoDB id on every row. Later tools need that id, and the model can't invent a valid ObjectId.</p>
<h3 id="heading-the-full-tool-set">The Full Tool Set</h3>
<p>The server registers nine tools:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>search_scholarships</code></td>
<td>Filter the catalog</td>
</tr>
<tr>
<td><code>get_scholarship</code></td>
<td>Return one full record</td>
</tr>
<tr>
<td><code>match_scholarships</code></td>
<td>Score awards against a student profile</td>
</tr>
<tr>
<td><code>save_scholarship</code></td>
<td>Bookmark an award</td>
</tr>
<tr>
<td><code>list_saved_scholarships</code></td>
<td>Show the shortlist</td>
</tr>
<tr>
<td><code>add_research_note</code></td>
<td>Attach a note</td>
</tr>
<tr>
<td><code>list_research_notes</code></td>
<td>Read notes back</td>
</tr>
<tr>
<td><code>get_upcoming_deadlines</code></td>
<td>Deadline window</td>
</tr>
<tr>
<td><code>compare_scholarships</code></td>
<td>Side-by-side of two or three ids</td>
</tr>
</tbody></table>
<p>That's enough for a research loop: find, inspect, match, save, annotate, and compare.</p>
<p>Resist the urge to add a <code>delete_everything</code> tool. Destructive tools need extra confirmation and aren't part of this workflow.</p>
<h2 id="heading-how-to-expose-resources-and-prompts">How to Expose Resources and Prompts</h2>
<p>Tools aren't the only way a host gets context.</p>
<h3 id="heading-resources">Resources</h3>
<p>A static resource is a fixed URI. The catalog fits that:</p>
<pre><code class="language-javascript">server.registerResource(
  "scholarship-catalog",
  "scholarship://catalog",
  {
    title: "Scholarship catalog",
    description: "Open scholarships currently stored in MongoDB",
    mimeType: "application/json",
  },
  async (uri) =&gt; {
    const catalog = await listCatalog();
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "application/json",
          text: JSON.stringify(catalog, null, 2),
        },
      ],
    };
  },
);
</code></pre>
<p>A resource template covers a family of URIs. Use <code>scholarship://item/{id}</code> rather than <code>scholarship://{id}</code>. If the pattern is <code>scholarship://{id}</code>, the URI <code>scholarship://catalog</code> becomes ambiguous.</p>
<pre><code class="language-javascript">import { ResourceTemplate } from "@modelcontextprotocol/server";

server.registerResource(
  "scholarship-record",
  new ResourceTemplate("scholarship://item/{id}", {
    list: async () =&gt; {
      const catalog = await listCatalog(20);
      return {
        resources: catalog.map((scholarship) =&gt; ({
          uri: `scholarship://item/${scholarship._id}`,
          name: scholarship.title,
          mimeType: "text/plain",
        })),
      };
    },
  }),
  {
    title: "Scholarship record",
    description: "Full details for one scholarship",
    mimeType: "text/plain",
  },
  async (uri, { id }) =&gt; {
    const scholarship = await getScholarshipById(id);
    return {
      contents: [
        {
          uri: uri.href,
          mimeType: "text/plain",
          text: scholarship
            ? formatScholarship(scholarship)
            : `No scholarship found with id ${id}.`,
        },
      ],
    };
  },
);
</code></pre>
<p><code>list</code> is required on a template. Pass <code>undefined</code> if you can't enumerate instances. Here you can, so the host can show a picker.</p>
<h3 id="heading-prompts">Prompts</h3>
<p>Prompts are workflows you want a person to start. The research plan prompt doesn't query MongoDB itself. It tells the model to use the tools, then structure the answer.</p>
<pre><code class="language-javascript">server.registerPrompt(
  "research-plan",
  {
    title: "Scholarship research plan",
    description: "Build a week-by-week research and application plan from a student profile.",
    argsSchema: z.object({
      fieldOfStudy: z.string(),
      educationLevel: z.string(),
      citizenship: z.string(),
      gpa: z.string(),
      weeks: z.string().optional(),
    }),
  },
  ({ fieldOfStudy, educationLevel, citizenship, gpa, weeks }) =&gt; ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: `Create a ${weeks || "6"}-week scholarship research plan for this student.

Field of study: ${fieldOfStudy}
Education level: ${educationLevel}
Citizenship: ${citizenship}
GPA: ${gpa}

Use the scholarship research tools to find real awards first. Then produce a shortlist, a week-by-week plan, and risks. Name actual scholarships and dates from the tool results.`,
        },
      },
    ],
  }),
);
</code></pre>
<p>Notice the instruction "use the scholarship research tools." A prompt isn't a substitute for tools. It's a script that makes tool use more likely and the output shape more consistent.</p>
<p>A second prompt, <code>application-checklist</code>, takes a scholarship id and asks for a document list and a backward calendar. That's the kind of repetitive work MCP prompts are good at.</p>
<p>Prompt arguments are strings in many hosts, even when the value is a number. Typing <code>gpa</code> as a string avoids a frustrating <code>expected number, received string</code> error from a slash-command form.</p>
<h2 id="heading-how-to-serve-mcp-over-express">How to Serve MCP Over Express</h2>
<p>This is the part that used to be a page of session-handling code. In SDK v2 it's a factory plus one route.</p>
<pre><code class="language-javascript">import "dotenv/config";
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { config } from "./config.js";
import { connectDatabase } from "./db.js";
import { createScholarshipServer } from "./mcp/server.js";

const mcpHandler = createMcpHandler(() =&gt; createScholarshipServer());
const nodeHandler = toNodeHandler(mcpHandler);

const app = createMcpExpressApp({
  host: config.host,
  allowedHosts: ["127.0.0.1", "localhost"],
});

app.get("/health", (_req, res) =&gt; {
  res.json({
    status: "ok",
    service: "scholarship-research-mcp",
    transport: "streamable-http",
  });
});

app.all("/mcp", (req, res) =&gt; {
  void nodeHandler(req, res, req.body);
});

async function start() {
  await connectDatabase();
  app.listen(config.port, config.host, () =&gt; {
    console.log(`Scholarship research MCP server listening on http://${config.host}:${config.port}/mcp`);
  });
}

start();
</code></pre>
<p>Walk through what each helper is doing.</p>
<p><code>createMcpHandler</code> takes a function that returns a fresh <code>McpServer</code>. It exposes a web-standard <code>fetch</code>. That's the same handler you would export from a Cloudflare Worker.</p>
<p><code>toNodeHandler</code> adapts that <code>fetch</code> to Express <code>(req, res)</code>. You pass <code>req.body</code> as the third argument because <code>createMcpExpressApp</code> already ran <code>express.json()</code>. If you omit the body, the adapter tries to read a stream Express already consumed.</p>
<p><code>createMcpExpressApp</code> is <code>express()</code> with two extras: JSON parsing, and Host/Origin checks. Those checks exist because of DNS rebinding. A malicious page can point its own domain at <code>127.0.0.1</code> and, without a Host check, your browser would treat the local MCP server as same-origin. The default bind is <code>127.0.0.1</code> for that reason. The <a href="https://ts.sdk.modelcontextprotocol.io/v2/serving/express.html">Express serving guide</a> covers this in more detail.</p>
<p><code>app.all("/mcp", ...)</code> is intentional. Streamable HTTP uses POST for JSON-RPC, and GET for SSE streams. Registering only POST will break some clients.</p>
<p>Shut the handler down on <code>SIGINT</code>:</p>
<pre><code class="language-javascript">process.on("SIGINT", async () =&gt; {
  await mcpHandler.close();
  process.exit(0);
});
</code></pre>
<p><code>close()</code> waits for in-flight requests. Then you can exit.</p>
<p>Add npm scripts:</p>
<pre><code class="language-json">{
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js",
    "seed": "node scripts/seed.js",
    "smoke": "node scripts/smoke-test.js"
  }
}
</code></pre>
<p><code>node --watch</code> is enough for local development. You don't need nodemon for this project.</p>
<h2 id="heading-how-to-seed-the-catalog">How to Seed the Catalog</h2>
<p>MCP tools against an empty database will work and return "no scholarships matched." That's correct, and also a bad first impression.</p>
<p>Put 20 to 30 realistic records in <code>data/scholarships.json</code>. Mix:</p>
<ul>
<li><p>Undergraduate and graduate awards</p>
</li>
<li><p>STEM and field-open awards</p>
</li>
<li><p>Country-specific programs and global ones</p>
</li>
<li><p>Rolling deadlines and hard dates</p>
</li>
<li><p>First-generation and women-only flags</p>
</li>
</ul>
<p>A seed script should replace the catalog, not append:</p>
<pre><code class="language-javascript">import "dotenv/config";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { connectDatabase } from "../src/db.js";
import { Scholarship } from "../src/models/index.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

async function seed() {
  await connectDatabase();
  const raw = await readFile(path.join(__dirname, "..", "data", "scholarships.json"), "utf8");
  await Scholarship.deleteMany({});
  const inserted = await Scholarship.insertMany(JSON.parse(raw));
  console.log(`Seeded ${inserted.length} scholarships.`);
  process.exit(0);
}

seed();
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">npm run seed
</code></pre>
<p>Treat the JSON as sample data. Names of well-known programs help the tutorial feel real. They also create a duty to say, clearly, that students must verify every number and date on the official site. The <code>applicationUrl</code> field exists so that reminder has somewhere to point.</p>
<p>If you later replace the JSON with a live source, keep the same schema. The MCP tools shouldn't care where the documents came from.</p>
<h2 id="heading-how-to-test-the-server">How to Test the Server</h2>
<p>Start MongoDB, seed, then start the process:</p>
<pre><code class="language-bash">npm run seed
npm start
</code></pre>
<p>You should see:</p>
<pre><code class="language-text">Scholarship research MCP server listening on http://127.0.0.1:3000/mcp
</code></pre>
<h3 id="heading-health-check">Health Check</h3>
<pre><code class="language-bash">curl -s http://127.0.0.1:3000/health
</code></pre>
<p>A JSON <code>status: ok</code> means Express is up. It doesn't mean MCP is wired correctly. For that, send a JSON-RPC request.</p>
<h3 id="heading-list-tools">List Tools</h3>
<pre><code class="language-bash">curl -s -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
</code></pre>
<p>The response is an SSE event whose <code>data:</code> line contains the JSON-RPC result. You should see all nine tools, each with a JSON Schema derived from Zod.</p>
<p>The <code>Accept</code> header matters. MCP Streamable HTTP can return JSON or an event stream. Asking for both is the compatible choice.</p>
<h3 id="heading-call-a-tool">Call a Tool</h3>
<pre><code class="language-bash">curl -s -X POST http://127.0.0.1:3000/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    "jsonrpc":"2.0",
    "id":2,
    "method":"tools/call",
    "params": {
      "name": "search_scholarships",
      "arguments": {
        "fieldOfStudy": "computer science",
        "limit": 3
      }
    }
  }'
</code></pre>
<p>You should get a numbered list with ids, deadlines, and GPA minimums. Copy one id and pass it to <code>get_scholarship</code>.</p>
<p>A small Node smoke test is nicer than raw curl once you're calling several methods. Parse the <code>data:</code> line, then print <code>result.content[0].text</code>. The repo includes <code>scripts/smoke-test.js</code> for that.</p>
<h3 id="heading-inspector">Inspector</h3>
<p>The <a href="https://modelcontextprotocol.io/docs/tools/inspector">MCP Inspector</a> is the official GUI for servers. Point it at <code>http://127.0.0.1:3000/mcp</code> and you can list tools, fill in arguments, and read resources without a host app in the way.</p>
<p>Use Inspector when a host "can't see" your server. If Inspector works and the host doesn't, the bug is in the host config. If Inspector fails, the bug is in your process.</p>
<h2 id="heading-how-to-connect-cursor-and-claude-desktop">How to Connect Cursor and Claude Desktop</h2>
<p>Keep <code>npm start</code> running. MCP over HTTP is a live server, not a one-shot CLI.</p>
<h3 id="heading-cursor">Cursor</h3>
<p>Add a server entry in Cursor's MCP settings. A Streamable HTTP server looks like this:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "scholarship-research": {
      "url": "http://127.0.0.1:3000/mcp"
    }
  }
}
</code></pre>
<p>Restart the MCP session if Cursor had a previous failed connection cached. Then ask:</p>
<blockquote>
<p>I am a first-generation undergraduate studying computer science in the United States, GPA 3.6. Use the scholarship research tools to build a shortlist and a six-week plan.</p>
</blockquote>
<p>You should see the host call <code>match_scholarships</code> or <code>search_scholarships</code>, then <code>get_scholarship</code> for the interesting rows, then maybe <code>save_scholarship</code>. If it never calls a tool, the server isn't actually connected. Check the MCP logs in Cursor before you change code.</p>
<p>You can also invoke the <code>research-plan</code> prompt from the host's prompt menu if it surfaces prompts.</p>
<h3 id="heading-claude-desktop">Claude Desktop</h3>
<p>Claude Desktop's config file lives at:</p>
<ul>
<li><p>macOS: <code>~/Library/Application Support/Claude/claude_desktop_config.json</code></p>
</li>
<li><p>Windows: <code>%APPDATA%\Claude\claude_desktop_config.json</code></p>
</li>
</ul>
<p>Claude Desktop prefers stdio. Bridge your HTTP server with <a href="https://www.npmjs.com/package/mcp-remote"><code>mcp-remote</code></a>:</p>
<pre><code class="language-json">{
  "mcpServers": {
    "scholarship-research": {
      "command": "npx",
      "args": ["-y", "mcp-remote", "http://127.0.0.1:3000/mcp"]
    }
  }
}
</code></pre>
<p>Restart Claude Desktop after you save the file. The scholarship tools should appear in the tool list.</p>
<p>Don't put MongoDB credentials in the Claude config. The Node process already loaded <code>.env</code>. The host only needs the URL.</p>
<h2 id="heading-how-a-research-session-runs">How a Research Session Runs</h2>
<p>Here's a realistic session against the seed catalog. The student is a first-generation undergraduate in computer science, a US citizen, GPA 3.6.</p>
<p>The host calls <code>match_scholarships</code> with that profile. The server returns ranked rows. A women-in-technology award and a first-generation program both score well, for different reasons. A graduate-only award never appears.</p>
<p>The host then calls <code>get_scholarship</code> on the top two ids. Each result includes the official URL, the requirement list, and the deadline. That's the moment to tell the student to open the URL. The model shouldn't be the last word on eligibility.</p>
<p>If an award is worth pursuing, the host calls <code>save_scholarship</code> with a <code>researcherId</code> such as <code>ada</code> and status <code>saved</code>. Later it can set <code>applying</code>. <code>list_saved_scholarships</code> is how a new chat picks up the shortlist. Persistence is the whole point of MongoDB here. Without it, every conversation starts from zero.</p>
<p><code>add_research_note</code> is for the messy human details: "Ask Dr. Chen for a recommendation by October 1." <code>get_upcoming_deadlines</code> is the weekly sweep. <code>compare_scholarships</code> is for the moment the student has two finalists and needs amount, GPA, and requirements in one view.</p>
<p>The <code>research-plan</code> prompt packages that loop. It injects the profile into a user message that tells the model to call tools first and then produce a week-by-week plan. If you invoke the prompt without a connected server, you get a generic essay. If you invoke it with this server running, you get named awards and real dates.</p>
<p>That's the product: a catalog the model can query, a shortlist it can't forget, and prompts that make the workflow repeatable.</p>
<h2 id="heading-how-the-matching-logic-works">How the Matching Logic Works</h2>
<p>It's worth slowing down on matching, because this is the part people are tempted to hand to the model.</p>
<p>Suppose the student is:</p>
<ul>
<li><p>GPA 3.6</p>
</li>
<li><p>Computer science</p>
</li>
<li><p>Undergraduate</p>
</li>
<li><p>United States citizen</p>
</li>
<li><p>First-generation</p>
</li>
<li><p>A woman</p>
</li>
</ul>
<p>The matcher walks every open award.</p>
<p>A women-in-technology scholarship with a 3.3 GPA minimum, CS as a listed field, and US eligibility scores high: education, GPA, citizenship, women-only flag, and field all hit. A first-generation program that is field-open also scores high, because <code>any</code> counts as a field match. A graduate-only award is skipped, even if the title looks relevant. A 3.8 GPA cutoff is skipped, even if everything else fits.</p>
<p>The tool then returns ranked rows with reasons:</p>
<pre><code class="language-text">1. Palantir Women in Technology Scholarship — score 90
   Why: education level matches; GPA 3.6 meets the 3.3 minimum; citizenship is eligible; women-only award matches; field of study matches
</code></pre>
<p>The model can still write a warm paragraph around that. It shouldn't be the component that decided eligibility.</p>
<p>If you extend this later, keep the same split. New eligibility rules belong in the service. New prose belongs in the prompt.</p>
<h2 id="heading-what-you-can-build-next">What You Can Build Next</h2>
<p>The server you have is complete enough to use. It's also a base for a more serious research tool.</p>
<p>First, you could replace the seed file with a live source. Official feeds such as <a href="https://www.grants.gov/">Grants.gov</a> and college-maintained lists are safer than scraping commercial aggregators. Keep your schema. Write an importer that upserts by a stable external id.</p>
<p>You could also add authentication. <code>createMcpExpressApp</code> works with <code>requireBearerAuth</code>. Map <code>researcherId</code> from the verified token instead of a tool argument. The <a href="https://www.npmjs.com/package/@modelcontextprotocol/express">Express adapter</a> documents that middleware.</p>
<p>Try adding full-text search. The schema already has a text index. For a large catalog, Atlas Search or a dedicated search engine will beat a pile of regex filters.</p>
<p>You can track documents, not just notes. A <code>documents</code> collection for transcripts, recommendation status, and essay drafts turns the shortlist into an application tracker.</p>
<p>You could also write tests around the matcher. Eligibility code is where silent bugs hurt people. A table of profiles and expected include/exclude lists will pay for itself.</p>
<p>And you could deploy it. Bind to <code>127.0.0.1</code> on your laptop. If you put this on a network, set <code>allowedHosts</code>, terminate TLS, and require a bearer token. An open MCP server is an open database with a helpful English interface.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this guide, you built a scholarship research MCP server that's more than a toy tool list.</p>
<p>You stored awards, shortlists, and notes in MongoDB. You exposed search, matching, and research-tracking as MCP tools, with Zod schemas the host can advertise to a model. You added resources for the catalog and prompts for repeatable workflows. You served the whole thing over Streamable HTTP with Express, including the Host header checks the SDK enables for localhost.</p>
<p>The pattern transfers. Any research workflow with a catalog and a personal working set can use the same three layers: a service that owns the rules, an <code>McpServer</code> factory that registers primitives, and a small Express app that speaks the protocol.</p>
<p>If you take one idea from this tutorial, take this one: let the model write the plan, and let your server decide what's true.</p>
<p>The sample catalog is for learning. Confirm every scholarship on its official application page before you apply, and before you tell someone else to apply.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Why You Should Never Send Emails Inside Your API Requests ]]>
                </title>
                <description>
                    <![CDATA[ Your signup endpoint looks fine in development. A user registers, you save the row, you call your email provider, and you return 201 Created. Everyone gets a welcome email. You ship it. Then productio ]]>
                </description>
                <link>https://www.freecodecamp.org/news/why-you-should-never-send-emails-inside-your-api-requests/</link>
                <guid isPermaLink="false">6a97368f8a7f1f043a223d7c</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                    <category>
                        <![CDATA[ architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chinedu Otutu ]]>
                </dc:creator>
                <pubDate>Tue, 01 Sep 2026 20:33:19 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8da5a9af-d7e5-4d0e-94cc-a11d24923023.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your signup endpoint looks fine in development. A user registers, you save the row, you call your email provider, and you return <code>201 Created</code>. Everyone gets a welcome email. You ship it.</p>
<p>Then production traffic arrives.</p>
<p>The email API takes 2 seconds instead of 200 milliseconds. A few requests time out. During a provider outage, every signup starts returning <code>500</code>. Support asks why users can create accounts but never get the confirmation link.</p>
<p>The bug isn't your email template. The bug is where you decided to send the email.</p>
<p>In this article, you'll learn why sending email inside the HTTP request path is a production trap, what goes wrong as you scale, and how to move email (and other side effects) into a background job queue so your API stays fast and reliable.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You'll get more out of this article if you know:</p>
<ul>
<li><p>How REST APIs work (request, response, status codes)</p>
</li>
<li><p>Basic Node.js or any backend language that can call an email API</p>
</li>
<li><p>What a background job or queue is at a high level</p>
</li>
</ul>
<p>You don't need prior experience with BullMQ, Redis, or a specific email provider.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-1-what-inside-the-request-really-means">1. What "Inside the Request" Really Means</a></p>
</li>
<li><p><a href="#heading-2-the-tempting-code-that-causes-the-problem">2. The Tempting Code That Causes the Problem</a></p>
</li>
<li><p><a href="#heading-3-why-this-breaks-in-production">3. Why This Breaks in Production</a></p>
</li>
<li><p><a href="#heading-4-the-rule-your-api-should-enqueue-work-not-do-it">4. The Rule: Your API Should Enqueue Work, Not Do It</a></p>
</li>
<li><p><a href="#heading-5-how-to-move-email-to-a-background-queue">5. How to Move Email to a Background Queue</a></p>
</li>
<li><p><a href="#heading-6-how-to-handle-retries-failures-and-idempotency">6. How to Handle Retries, Failures, and Idempotency</a></p>
</li>
<li><p><a href="#heading-7-what-else-should-leave-the-request-path">7. What Else Should Leave the Request Path</a></p>
</li>
<li><p><a href="#heading-8-a-practical-checklist">8. A Practical Checklist</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-1-what-inside-the-request-really-means">1. What "Inside the Request" Really Means</h2>
<p>When a client hits your API, the request path is everything that must finish before you send the HTTP response.</p>
<p>That usually includes:</p>
<ul>
<li><p>validating input</p>
</li>
<li><p>checking auth</p>
</li>
<li><p>writing to your database</p>
</li>
<li><p>returning JSON</p>
</li>
</ul>
<p>It should almost never include:</p>
<ul>
<li><p>calling an SMTP server</p>
</li>
<li><p>waiting on Resend, SendGrid, SES, Mailgun, or Postmark</p>
</li>
<li><p>rendering a big HTML template and uploading attachments</p>
</li>
<li><p>retrying a flaky third-party network call</p>
</li>
</ul>
<p>Email providers are external systems. They have their own latency, rate limits, and outages. If your API waits on them, their problems become your users' problems.</p>
<pre><code class="language-text">Bad path (synchronous):

Client ──▶ API ──▶ Database ──▶ Email provider ──▶ Client
                         ▲
                         └── user waits for all of this
</code></pre>
<h2 id="heading-2-the-tempting-code-that-causes-the-problem">2. The Tempting Code That Causes the Problem</h2>
<p>Here's the pattern most teams start with. It's clear, short, and works on your laptop.</p>
<pre><code class="language-typescript">app.post('/signup', async (c) =&gt; {
  const body = await c.req.json();

  const user = await db.users.create({
    email: body.email,
    passwordHash: await hash(body.password),
  });

  // This is the trap.
  await emailClient.send({
    to: user.email,
    subject: 'Confirm your account',
    html: renderConfirmEmail(user),
  });

  return c.json({ id: user.id }, 201);
});
</code></pre>
<p>The request can't finish until the email provider answers. If that call is slow, your response is slow. If that call throws, your signup either fails or you catch the error and silently skip the email.</p>
<p>Neither option is good.</p>
<h2 id="heading-3-why-this-breaks-in-production">3. Why This Breaks in Production</h2>
<h3 id="heading-your-response-time-becomes-someone-elses-sla">Your Response Time Becomes Someone Else's SLA</h3>
<p>A healthy signup endpoint should often respond in tens or hundreds of milliseconds. Email APIs regularly take longer than that. Under load, they take much longer.</p>
<p>Your p95 latency is no longer owned by your code. It's owned by the slowest third-party call on the path.</p>
<h3 id="heading-failures-create-ugly-tradeoffs">Failures Create Ugly Tradeoffs</h3>
<p>If <code>send()</code> throws, you have two bad choices:</p>
<ol>
<li><p>Fail the whole request: the user sees an error even though the account may already exist</p>
</li>
<li><p>Swallow the error: the user is created, but never gets the email</p>
</li>
</ol>
<p>That second case is especially painful with confirmation or password-reset flows. The database says success but the inbox says nothing.</p>
<h3 id="heading-timeouts-cascade">Timeouts Cascade</h3>
<p>API gateways, load balancers, and browsers all have timeouts. A slow email call can turn a successful business action into a client-side failure. The user retries. Now you may create duplicate users or send duplicate emails.</p>
<h3 id="heading-rate-limits-punish-traffic-spikes">Rate Limits Punish Traffic Spikes</h3>
<p>Email providers rate-limit you. Product launches, digests, and password-reset spikes can all hit those limits. If sending happens inside the request, a rate limit error becomes a user-facing API error.</p>
<h3 id="heading-you-cant-retry-cleanly">You Can't Retry Cleanly</h3>
<p>Background jobs can retry with backoff. HTTP requests can't. Once you've returned a response, the request is over. If the email failed and you already told the client "success," you need another system to repair that. That system is a queue.</p>
<pre><code class="language-text">What the user experiences during a provider slowdown:

Signup click
   │
   ▼
API waits on email provider .............. 2s ... 5s ... timeout
   │
   ▼
"Something went wrong"
   │
   ▼
User clicks again → duplicate attempts, confused support tickets
</code></pre>
<h2 id="heading-4-the-rule-your-api-should-enqueue-work-not-do-it">4. The Rule: Your API Should Enqueue Work, Not Do It</h2>
<p>Treat email as asynchronous work that must happen <em>because of</em> the request, not <em>inside</em> the request.</p>
<p>The request path should:</p>
<ol>
<li><p>validate the input</p>
</li>
<li><p>commit the business change</p>
</li>
<li><p>enqueue a job that describes the email</p>
</li>
<li><p>return quickly</p>
</li>
</ol>
<p>A worker somewhere else should:</p>
<ol>
<li><p>pick up the job</p>
</li>
<li><p>render the template</p>
</li>
<li><p>call the email provider</p>
</li>
<li><p>retry on failure</p>
</li>
<li><p>record the result</p>
</li>
</ol>
<pre><code class="language-text">Good path (asynchronous):

Client ──▶ API ──▶ Database ──▶ Queue ──▶ Client (fast response)
                                   │
                                   ▼
                                Worker ──▶ Email provider
</code></pre>
<p>This is the same idea behind processing uploads, generating PDFs, calling AI APIs, or syncing data to a CRM. Email is just the most common example.</p>
<h2 id="heading-5-how-to-move-email-to-a-background-queue">5. How to Move Email to a Background Queue</h2>
<p>Here's a minimal Node.js shape using <a href="https://docs.bullmq.io/">BullMQ</a> and Redis. The exact library matters less than the boundary.</p>
<h3 id="heading-define-the-job">Define the Job</h3>
<pre><code class="language-typescript">// jobs/email.ts
export type SendEmailJob = {
  to: string;
  template: 'confirm-account' | 'password-reset' | 'welcome';
  variables: Record&lt;string, string&gt;;
  idempotencyKey: string;
};
</code></pre>
<h3 id="heading-enqueue-from-the-api">Enqueue from the API</h3>
<pre><code class="language-typescript">import { emailQueue } from '../queues/email-queue';

app.post('/signup', async (c) =&gt; {
  const body = await c.req.json();

  const user = await db.users.create({
    email: body.email,
    passwordHash: await hash(body.password),
  });

  await emailQueue.add(
    'send-email',
    {
      to: user.email,
      template: 'confirm-account',
      variables: {
        name: user.name,
        confirmUrl: `https://example.com/confirm/${user.confirmToken}`,
      },
      idempotencyKey: `confirm-account:${user.id}`,
    },
    {
      jobId: `confirm-account:${user.id}`,
      attempts: 5,
      backoff: { type: 'exponential', delay: 2000 },
      removeOnComplete: 1000,
      removeOnFail: 5000,
    },
  );

  return c.json({ id: user.id }, 201);
});
</code></pre>
<p>Notice what changed. The API still creates the user. It no longer waits for SMTP. It waits for Redis to accept a job, which is usually far cheaper and more predictable than waiting for an email provider.</p>
<h3 id="heading-process-the-job-in-a-worker">Process the Job in a Worker</h3>
<pre><code class="language-typescript">// worker/email-worker.ts
import { Worker } from 'bullmq';
import { emailClient } from '../lib/email-client';
import { renderTemplate } from '../lib/templates';

new Worker(
  'email',
  async (job) =&gt; {
    const { to, template, variables } = job.data;

    await emailClient.send({
      to,
      subject: subjectFor(template),
      html: renderTemplate(template, variables),
    });
  },
  {
    connection: redisConnection,
    concurrency: 5,
  },
);
</code></pre>
<p>Run the worker as a separate process from the API. That way an email outage slows the worker, not every HTTP response.</p>
<h3 id="heading-return-the-right-status">Return the Right Status</h3>
<p>For signup, <code>201 Created</code> is still correct if the account exists and the confirmation email is queued.</p>
<p>For endpoints that only trigger work, prefer <code>202 Accepted</code> and return a job or delivery id when the client needs to track progress.</p>
<h2 id="heading-6-how-to-handle-retries-failures-and-idempotency">6. How to Handle Retries, Failures, and Idempotency</h2>
<p>Moving email to a queue isn't enough. You also need failure rules.</p>
<h3 id="heading-retry-transient-errors">Retry Transient Errors</h3>
<p>Network blips, <code>429 Too Many Requests</code>, and <code>503 Service Unavailable</code> should retry with backoff. Permanent errors like an invalid recipient should fail fast and land in a dead-letter queue or failed-jobs table.</p>
<h3 id="heading-make-jobs-idempotent">Make Jobs Idempotent</h3>
<p>Workers can run more than once. Use a stable <code>jobId</code> or <code>idempotencyKey</code> so a retry doesn't send five welcome emails for one signup.</p>
<p>A practical approach:</p>
<ul>
<li><p>derive the key from the business event: <code>confirm-account:user_123</code></p>
</li>
<li><p>store provider message ids when a send succeeds</p>
</li>
<li><p>skip work if that key was already delivered</p>
</li>
</ul>
<h3 id="heading-dont-lose-the-business-event">Don't Lose the Business Event</h3>
<p>If the process crashes between <code>db.users.create()</code> and <code>emailQueue.add()</code>, you can lose the email.</p>
<p>There are two common ways to reduce that risk:</p>
<ol>
<li><p><strong>Outbox pattern</strong>: write the email intent into an <code>outbox</code> table in the same database transaction as the user row. A separate publisher reads the outbox and enqueues jobs.</p>
</li>
<li><p><strong>Transactional enqueue where supported</strong>: keep the write and the enqueue as close together as possible, and run reconciliation jobs for missed sends.</p>
</li>
</ol>
<p>For many apps, a simple queue plus monitoring is enough at first. Add an outbox when missed emails become a real reliability requirement.</p>
<h3 id="heading-separate-urgent-mail-from-bulk-mail">Separate Urgent Mail from Bulk Mail</h3>
<p>Password reset and OTP emails shouldn't sit behind a newsletter blast.</p>
<p>Use priorities or separate queues:</p>
<ul>
<li><p>high priority: OTP, password reset, confirmation</p>
</li>
<li><p>normal priority: receipts, onboarding</p>
</li>
<li><p>low priority: digests, marketing</p>
</li>
</ul>
<p>That keeps user-blocking email fast even when bulk traffic spikes.</p>
<h2 id="heading-7-what-else-should-leave-the-request-path">7. What Else Should Leave the Request Path</h2>
<p>If a task calls a third-party network API, can take more than a few hundred milliseconds, or it needs retries, keep it out of the request path. Same if it can fail without undoing the main business action or it does CPU-heavy work like image or PDF generation.</p>
<p>Here are some common examples of these types of tasks:</p>
<ul>
<li><p>emails and SMS</p>
</li>
<li><p>webhook fan-out</p>
</li>
<li><p>search indexing</p>
</li>
<li><p>thumbnail generation</p>
</li>
<li><p>invoice PDF creation</p>
</li>
<li><p>CRM sync</p>
</li>
<li><p>AI transcription or summarization</p>
</li>
<li><p>slow report generation</p>
</li>
</ul>
<p>Your API should confirm the intent and persist the state. Workers should do the slow parts.</p>
<h2 id="heading-8-a-practical-checklist">8. A Practical Checklist</h2>
<p>Before you ship a feature that sends email, ask:</p>
<ol>
<li><p>Does the user need the email to finish before seeing success?</p>
</li>
<li><p>Can the email provider being down break this endpoint?</p>
</li>
<li><p>If sending fails after the database write, how do we recover?</p>
</li>
<li><p>If the job runs twice, will the user get duplicate messages?</p>
</li>
<li><p>Do OTP and password-reset emails have a faster lane than bulk email?</p>
</li>
<li><p>Can we see failed jobs in logs, metrics, or a dashboard?</p>
</li>
<li><p>Do we alert when the queue depth or failure rate spikes?</p>
</li>
</ol>
<p>If you can't answer those questions, the design isn't ready for production.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Sending email inside your API request couples your response time and your error rate to a third-party system you don't control. It feels simple in a demo and becomes expensive in production.</p>
<p>The better default is simple:</p>
<ul>
<li><p>commit the business change</p>
</li>
<li><p>enqueue the email</p>
</li>
<li><p>return quickly</p>
</li>
<li><p>let a worker send, retry, and report failures</p>
</li>
</ul>
<p>That pattern makes your API faster, your email delivery more reliable, and your failure modes much easier to reason about.</p>
<p>Once you make that split for email, you'll start seeing the same boundary everywhere: if the user shouldn't wait for it, it doesn't belong in the request.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Get Reliable Structured Data Out of an LLM ]]>
                </title>
                <description>
                    <![CDATA[ Most tutorials about calling a language model end at JSON.parse(response.content). That line works on your first ten test cases. Then you ship, and somewhere around request four hundred the model retu ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-get-reliable-structured-data-out-of-an-llm/</link>
                <guid isPermaLink="false">6a908843ac9ce68300a83a4c</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vineeth Pawar ]]>
                </dc:creator>
                <pubDate>Thu, 27 Aug 2026 18:56:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/be161971-7a90-496c-899a-526492046265.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most tutorials about calling a language model end at <code>JSON.parse(response.content)</code>. That line works on your first ten test cases. Then you ship, and somewhere around request four hundred the model returns a date it invented, or eight array items when your schema allows five, or a perfectly valid JSON object with one field quietly missing.</p>
<p>I ran into this while building Temploracraft, a résumé tool that takes an uploaded document and turns it into structured data the application can edit.</p>
<p>The input is genuinely unpredictable: two-column PDFs, tables that aren't really tables, and dates written in about fourteen different formats. The output has to be strict, because every extracted field lands in a form that a person is going to look at. When the model gets a date wrong, the user notices in about two seconds.</p>
<p>This article is about the layer that sits between "the model returned some text" and "my application has data it can trust." It covers the three mechanisms for constraining output, why designing the schema first beats writing a longer prompt, how to build a retry loop that doesn't set money on fire, and what to do about the failures that no retry will ever fix.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-prompting-for-json-isnt-enough">Why Prompting for JSON Isn't Enough</a></p>
</li>
<li><p><a href="#heading-the-three-ways-to-constrain-output">The Three Ways to Constrain Output</a></p>
</li>
<li><p><a href="#heading-start-with-the-schema-not-the-prompt">Start with the Schema, Not the Prompt</a></p>
</li>
<li><p><a href="#heading-validation-is-two-jobs-not-one">Validation Is Two Jobs, Not One</a></p>
</li>
<li><p><a href="#heading-building-a-retry-loop-that-doesnt-burn-tokens">Building a Retry Loop That Doesn't Burn Tokens</a></p>
</li>
<li><p><a href="#heading-streaming-structured-output">Streaming Structured Output</a></p>
</li>
<li><p><a href="#heading-the-failures-you-cant-retry-away">The Failures You Can't Retry Away</a></p>
</li>
<li><p><a href="#heading-what-this-actually-costs">What This Actually Costs</a></p>
</li>
<li><p><a href="#heading-when-you-dont-need-any-of-this">When You Don't Need Any of This</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along comfortably, you'll want a few things in place:</p>
<ul>
<li><p><strong>Working knowledge of TypeScript:</strong> The examples use type inference and generics lightly, and you should be able to read a type annotation without pausing.</p>
</li>
<li><p><strong>You have called a language model API at least once:</strong> You don't need to be an expert, but you should know what a system prompt is and roughly what a token is.</p>
</li>
<li><p><strong>Familiarity with JSON Schema is helpful but not required:</strong> I explain the parts that matter as they come up.</p>
</li>
<li><p><strong>Node.js 18 or later</strong> if you want to run the examples, since they use the native <code>fetch</code> and async iterators.</p>
</li>
</ul>
<p>The code samples use <a href="https://zod.dev">Zod</a> for schema definition and the Anthropic SDK for model calls, but every technique here translates directly to other validation libraries and other providers. The ideas matter more than the specific packages.</p>
<h2 id="heading-why-prompting-for-json-isnt-enough">Why Prompting for JSON Isn't Enough</h2>
<p>The first instinct when you need structured data is to ask for it politely. You write something like "Respond with valid JSON matching this shape, and do not include any other text," you paste an example, and it works. It keeps working through development. It works in your demo.</p>
<p>The problem is that a language model generates one token at a time based on probability, and your instruction is only one influence among many. It competes with the model's training, the shape of the input document, and whatever the model produced in the preceding few hundred tokens. Most of the time your instruction wins. Occasionally it does not.</p>
<p>Here are failures I've actually collected from a production extraction pipeline, all from a model that was explicitly told to return strict JSON:</p>
<ul>
<li><p>The model wrapped the response in a Markdown code fence, despite being told twice not to.</p>
</li>
<li><p>It returned <code>"2019 - Present"</code> as a single string where the schema defined separate <code>startDate</code> and <code>endDate</code> fields.</p>
</li>
<li><p>It invented an <code>endDate</code> of <code>"2024-12-31"</code> for a role the document clearly marked as current.</p>
</li>
<li><p>It returned the string <code>"null"</code> instead of an actual <code>null</code>.</p>
</li>
<li><p>It emitted seven bullet points for a role where the schema set a maximum of five.</p>
</li>
<li><p>It truncated mid-object because the response hit the output token limit.</p>
</li>
</ul>
<p>Notice that these aren't all the same kind of failure. The code fence and the truncation are syntax problems, and you can often fix them locally without calling the model again. The merged date string and the extra bullets are schema problems, where the JSON parses fine but doesn't match the shape you need. The invented end date is a semantic problem, where the output is both valid JSON and schema-conformant but factually wrong about the source document.</p>
<p>Those three categories need three different responses, and conflating them is the most common architectural mistake I see in extraction code. The rest of this article is largely about separating them.</p>
<h2 id="heading-the-three-ways-to-constrain-output">The Three Ways to Constrain Output</h2>
<p>Before writing any validation code, it's worth understanding what the API itself can enforce for you. There are three mechanisms, and they give you meaningfully different guarantees.</p>
<p><strong>JSON mode</strong> is the weakest of the three. You set a flag such as <code>response_format: { type: "json_object" }</code>, and the provider guarantees the response will be syntactically valid JSON. That's genuinely useful, because it eliminates the code fence and truncation problems in one move. What it doesn't do is guarantee anything about the shape. You can get valid JSON with the wrong keys, the wrong types, or a completely different structure than you asked for.</p>
<p><strong>Tool calling</strong> is the mechanism I reach for most often. You describe a function with a JSON Schema for its parameters, then force the model to call it. The model returns arguments matching that schema rather than free text. Support is broad, the schema travels with the request so you're not duplicating it in the prompt, and providers tend to constrain the output more aggressively than they do in plain JSON mode.</p>
<p>Here's what that looks like with the Anthropic SDK:</p>
<pre><code class="language-ts">const response = await client.messages.create({
  model: "claude-sonnet-5",
  max_tokens: 4096,
  tools: [
    {
      name: "emit_resume",
      description: "Return the parsed resume as structured data.",
      input_schema: jsonSchema,
    },
  ],
  tool_choice: { type: "tool", name: "emit_resume" },
  messages: [{ role: "user", content: resumeText }],
});

const block = response.content.find((b) =&gt; b.type === "tool_use");
const candidate = block?.input;
</code></pre>
<p>The <code>tool_choice</code> field is the important part. Without it the model decides whether to call the tool, and sometimes it will just answer in prose instead. Forcing the specific tool removes that branch entirely.</p>
<p><strong>Constrained decoding</strong> is the strongest option and the least widely available. Instead of asking the model to follow a schema, the runtime masks the token sampler at every step so that tokens which would produce invalid output can't be selected at all. Invalid output becomes impossible rather than unlikely.</p>
<p>OpenAI exposes a version of this through strict structured outputs, and if you run models locally you can use GBNF grammars in llama.cpp or a library such as Outlines.</p>
<p>The tradeoff is that constrained decoding can push the model into awkward corners. If the schema demands a field the document genuinely doesn't contain, the model can't decline, so it fills the slot with something. You've traded a parsing failure for a hallucination, which is harder to detect. I make required fields nullable for exactly this reason, which I'll come back to shortly.</p>
<p>My default is tool calling with a nullable-heavy schema, plus validation on top. That combination gives most of the benefit without the issues.</p>
<h2 id="heading-start-with-the-schema-not-the-prompt">Start with the Schema, Not the Prompt</h2>
<p>The instinct when output quality is poor is to write a longer prompt. More examples, more emphasis, and more capital letters. This helps a little and scales badly, because the prompt is prose and prose isn't enforceable.</p>
<p>A better approach is to treat the schema as the primary artifact and let everything else derive from it. Define it once, and use that single definition to generate the TypeScript type, the JSON Schema sent to the API, and the runtime validator. When the shape changes, all three change together, and there's no way for them to drift apart.</p>
<p>With Zod that looks like this:</p>
<pre><code class="language-ts">import { z } from "zod";

const YearMonth = z
  .string()
  .regex(/^\d{4}-\d{2}$/, "Expected a YYYY-MM date");

const Role = z.object({
  company: z.string().min(1),
  title: z.string().min(1),
  startDate: YearMonth,
  endDate: YearMonth.nullable(),
  bullets: z.array(z.string().min(1)).min(1).max(5),
});

const Resume = z.object({
  name: z.string().min(1),
  email: z.string().email().nullable(),
  roles: z.array(Role),
});

export type Resume = z.infer&lt;typeof Resume&gt;;
</code></pre>
<p>Three details in there are doing real work.</p>
<p>The <code>YearMonth</code> regex is narrower than <code>z.string()</code>, and that narrowness is the point. A bare string field invites the model to return <code>"January 2019"</code> or <code>"2019 - Present"</code> or <code>"01/2019"</code>, and all of those pass validation. Constraining the format at the schema level means the mismatch surfaces immediately instead of three layers deeper in your date-handling code.</p>
<p>The <code>endDate</code> field is nullable rather than optional. This is the single change that most improved extraction quality for me. An optional field lets the model quietly omit it, and you can't distinguish "the document did not say" from "the model forgot." A nullable field forces an explicit decision, and <code>null</code> is a meaningful answer that means the role is current.</p>
<p>The <code>max(5)</code> on bullets encodes a product constraint directly into the contract rather than trimming the array afterward. If the model exceeds it, you want to know, because it usually means the model is padding rather than extracting.</p>
<p>To send the schema to the API, derive the JSON Schema from the same definition:</p>
<pre><code class="language-ts">import { zodToJsonSchema } from "zod-to-json-schema";

const jsonSchema = zodToJsonSchema(Resume, { target: "openApi3" });
</code></pre>
<p>One habit worth adopting: write a <code>.describe()</code> on any field where the name alone is ambiguous. Those descriptions end up in the JSON Schema, which means they reach the model as part of the tool definition, which means they function as targeted, structured prompt instructions attached to exactly the field they concern.</p>
<pre><code class="language-ts">const Role = z.object({
  company: z.string().min(1),
  title: z.string().min(1).describe("The person's job title, not the team name"),
  startDate: YearMonth,
  endDate: YearMonth.nullable().describe("null if this role is current"),
  bullets: z
    .array(z.string().min(1))
    .min(1)
    .max(5)
    .describe("Verbatim from the document. Do not rewrite or summarise."),
});
</code></pre>
<p>That last description eliminated an entire class of failure for me, where the model would helpfully improve the wording of a person's own bullet points.</p>
<h2 id="heading-validation-is-two-jobs-not-one">Validation Is Two Jobs, Not One</h2>
<p>Once the response comes back, it's tempting to run <code>schema.parse()</code> and consider the job done. Zod will tell you whether the shape is right, and if it is, you move on.</p>
<p>But shape validation and semantic validation are different jobs, and only the first one is free. Zod can tell you that <code>endDate</code> is a string matching <code>YYYY-MM</code>. It can't tell you that the end date falls before the start date, or that the date is in the future, or that a role listed as current also has an end date. Those are all schema-valid and all wrong.</p>
<p>So run two passes. The first is structural and comes from the schema. The second is a plain function that encodes what you know about the domain:</p>
<pre><code class="language-ts">function findSemanticProblems(resume: Resume): string[] {
  const problems: string[] = [];
  const currentMonth = new Date().toISOString().slice(0, 7);

  for (const role of resume.roles) {
    if (role.startDate &gt; currentMonth) {
      problems.push(`${role.company}: start date is in the future`);
    }
    if (role.endDate &amp;&amp; role.endDate &lt; role.startDate) {
      problems.push(`${role.company}: end date precedes start date`);
    }
  }

  const currentRoles = resume.roles.filter((r) =&gt; r.endDate === null);
  if (currentRoles.length &gt; 1) {
    problems.push("More than one role is marked as current");
  }

  return problems;
}
</code></pre>
<p>None of this is clever code, and that's rather the point. It's ordinary business logic that happens to be checking a model's work rather than a user's. Write it as you discover failures, and treat each new check as a permanent regression test for a mistake the model made once.</p>
<p>There's a useful asymmetry here. Semantic problems are often better surfaced to the user than retried, because the model frequently can't do better with the information available. If a document genuinely lists two current roles, that's what the document says, and asking the model again won't change it.</p>
<h2 id="heading-building-a-retry-loop-that-doesnt-burn-tokens">Building a Retry Loop That Doesn't Burn Tokens</h2>
<p>When validation fails, the naïve fix is to call the model again with the same prompt and hope for a different sample. That works often enough to feel reasonable and is wasteful enough to notice on the bill, because you're paying full input cost for a request that has already mostly succeeded.</p>
<p>Two changes make a large difference.</p>
<p>The first is to attempt a local repair before spending anything. A meaningful share of failures are cosmetic, and you can fix them with string handling:</p>
<pre><code class="language-ts">function salvage(raw: string): string {
  let text = raw.trim();

  // Strip a Markdown fence the model added despite instructions.
  const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
  if (fenced) text = fenced[1];

  // Drop any prose before the first brace or after the last one.
  const start = text.indexOf("{");
  const end = text.lastIndexOf("}");
  if (start !== -1 &amp;&amp; end &gt; start) text = text.slice(start, end + 1);

  return text;
}
</code></pre>
<p>The second is that when you do call the model again, you should send a repair request rather than a fresh attempt. Include the original prompt, the output that failed, and the specific validation errors. The model has already done the hard extraction work, and you're asking it to fix a small number of named problems rather than start over. Repairs converge faster and produce shorter outputs, which means they cost less.</p>
<pre><code class="language-ts">async function requestRepair(
  originalPrompt: string,
  badOutput: string,
  error: z.ZodError,
): Promise&lt;string&gt; {
  const issues = error.issues
    .map((issue) =&gt; `${issue.path.join(".") || "root"}: ${issue.message}`)
    .join("\n");

  const response = await client.messages.create({
    model: "claude-sonnet-5",
    max_tokens: 4096,
    messages: [
      { role: "user", content: originalPrompt },
      { role: "assistant", content: badOutput },
      {
        role: "user",
        content:
          `That output failed validation with these problems:\n${issues}\n\n` +
          `Return the corrected JSON only. Keep everything that was already correct.`,
      },
    ],
  });

  return response.content[0].type === "text" ? response.content[0].text : "";
}
</code></pre>
<p>Putting it together, the full loop separates the failure classes and caps the spend:</p>
<pre><code class="language-ts">type Outcome&lt;T&gt; =
  | { ok: true; value: T; attempts: number }
  | { ok: false; error: string; attempts: number };

async function extract&lt;T&gt;(
  schema: z.ZodType&lt;T&gt;,
  prompt: string,
  maxAttempts = 3,
): Promise&lt;Outcome&lt;T&gt;&gt; {
  let lastRaw = "";
  let lastError: z.ZodError | null = null;

  for (let attempt = 1; attempt &lt;= maxAttempts; attempt++) {
    lastRaw =
      attempt === 1
        ? await callModel(prompt)
        : await requestRepair(prompt, lastRaw, lastError!);

    let candidate: unknown;
    try {
      candidate = JSON.parse(salvage(lastRaw));
    } catch {
      continue; // Syntax failure. Try again without a schema error to report.
    }

    const result = schema.safeParse(candidate);
    if (result.success) {
      return { ok: true, value: result.data, attempts: attempt };
    }
    lastError = result.error;
  }

  return {
    ok: false,
    error: lastError?.message ?? "Output was never parseable",
    attempts: maxAttempts,
  };
}
</code></pre>
<p>Two things about this loop are deliberate. It returns the attempt count, which you should log, because attempts per success is the single most useful health metric for an extraction pipeline. And it caps attempts at three. If three tries haven't produced valid output, a fourth rarely helps, and by then you're better off degrading gracefully than continuing to pay.</p>
<p>One more distinction worth respecting: a rate limit error and a schema validation error aren't the same failure and shouldn't share a retry policy. Rate limits want exponential backoff, because the problem is timing. Schema failures want an immediate repair request, because the problem is content, and waiting changes nothing.</p>
<h2 id="heading-streaming-structured-output">Streaming Structured Output</h2>
<p>Streaming and structured output pull against each other. Streaming exists so the user sees progress before the response finishes, but you can't parse a JSON object until the closing brace arrives. If your extraction takes twelve seconds, the best options are to show a spinner for twelve seconds or find a way to stream something meaningful.</p>
<p>There are two reasonable approaches.</p>
<p>The first is a partial JSON parser, which takes an incomplete string and returns the largest valid structure it can infer, closing open braces and dropping the trailing incomplete value. Libraries such as <code>best-effort-json-parser</code> do this. It works, and it's the right choice when the output is genuinely one large object. The cost is that intermediate states can be misleading, because a field can appear with a truncated value that looks complete.</p>
<p>The second approach, which I prefer when the output is a list, is to change the output format so that streaming is natural. Instead of asking for one array of objects, ask for one object per line. Each line is independently parseable, so you can validate and emit items the moment they complete:</p>
<pre><code class="language-ts">const stream = client.messages.stream({
  model: "claude-sonnet-5",
  max_tokens: 4096,
  messages: [{ role: "user", content: prompt }],
});

let buffer = "";

for await (const event of stream) {
  if (event.type !== "content_block_delta") continue;
  buffer += event.delta.text ?? "";

  const lines = buffer.split("\n");
  buffer = lines.pop() ?? ""; // Keep the incomplete tail for the next chunk.

  for (const line of lines) {
    if (!line.trim()) continue;

    try {
      const parsed = Role.safeParse(JSON.parse(line));
      if (parsed.success) onRole(parsed.data);
    } catch {
      // A malformed line is dropped rather than failing the whole stream.
    }
  }
}
</code></pre>
<p>The buffer handling is the part that people get wrong. Network chunks don't align with line boundaries, so a chunk will frequently end mid-object. Popping the final element back into the buffer and carrying it forward is what makes the loop correct.</p>
<p>This pattern gives you per-item validation for free, which is a real advantage over parsing one large object. One bad item doesn't invalidate the rest of the extraction.</p>
<h2 id="heading-the-failures-you-cant-retry-away">The Failures You Can't Retry Away</h2>
<p>Everything so far assumes the model can produce the right answer if you ask correctly. Some failures don't work that way, and treating them as retry candidates wastes money while producing confidently wrong data.</p>
<p>The most important of these is extraction from something the source doesn't contain. If a résumé genuinely has no email address and your schema requires one, the model will produce something plausible.</p>
<p>Retrying produces a different plausible thing. This is the failure mode that constrained decoding makes worse rather than better, because the grammar guarantees a well-formed value in a slot that should've been empty. The fix is at the schema level, which is why nearly every field in my extraction schemas is nullable.</p>
<p>Ambiguity in the source is a similar case. When a document lists a date range next to two different job titles, there's no correct extraction, only a guess. Retrying gives you a different guess with the same confidence. These situations want a confidence signal in the schema and a review step in the interface, not another API call.</p>
<p>Then there's silent truncation. If the response hits the output token limit mid-object, you get syntactically broken JSON, and a naïve retry loop treats it as a transient parse failure and tries again with the same limit, failing identically each time. Check the stop reason on the response. If the model stopped because it ran out of tokens, retrying without raising the limit or splitting the input is guaranteed to fail again.</p>
<p>The general principle is that your retry policy should ask what kind of failure this is before deciding whether repeating the call could possibly help.</p>
<h2 id="heading-what-this-actually-costs">What This Actually Costs</h2>
<p>Every layer above has a price, and it's worth measuring rather than assuming.</p>
<p>The main figure to track is <strong>attempts per successful extraction</strong>. Log it on every request and watch the p95 rather than the mean, because the mean hides the tail where the money goes. When a schema change causes a quality regression, this number moves before anything else does.</p>
<p>Prompt caching matters more here than in most workloads. Extraction prompts are unusually cache-friendly because the system prompt, the schema, and any few-shot examples are byte-identical on every request, and only the document changes.</p>
<p>On a pipeline where the schema and instructions ran to a couple of thousand tokens, moving that block into a cached prefix cut input cost substantially, and the saving compounds with every retry because repairs re-send the same prefix.</p>
<p>Repair requests are cheaper than fresh attempts for a reason worth understanding. The input grows, since you're now sending the original prompt plus the failed output plus the error list. But the output shrinks considerably, because the model is correcting a handful of fields rather than generating the full document again. Output tokens are the expensive direction on most providers, so the trade is usually favourable.</p>
<p>Two other things are worth instrumenting from the start: the distribution of validation error paths, which tells you exactly which schema fields are causing trouble and is far more actionable than an overall failure rate, and your local-repair hit rate, since if <code>salvage()</code> is fixing a large fraction of responses you have a prompt problem you can solve once rather than paying for repeatedly.</p>
<h2 id="heading-when-you-dont-need-any-of-this">When You Don't Need Any of This</h2>
<p>This machinery earns its place in a specific set of circumstances, and it's genuinely overkill outside them.</p>
<p>If the model's output goes straight to a human who will read it as prose, you don't need structured output at all. A chat interface, a summary, or a draft email all have a person as the validator, and adding a schema in front of that just constrains the model for no benefit.</p>
<p>If you're extracting one or two fields rather than a document's worth, a well-targeted prompt with a light <code>safeParse</code> and a single retry will serve you fine. The retry loop, semantic validation layer, and streaming parser are answers to problems that appear at scale and with schema complexity.</p>
<p>If your volume is low and a human reviews every result anyway, the validation layer is duplicating work someone is already doing. Surface the raw output, let the reviewer correct it, and spend the engineering time elsewhere.</p>
<p>And if you're still exploring what the feature should be, resist building this early. The schema is the most expensive thing to change once extraction code, validation rules, and stored data all depend on it. Prototype loosely, learn what fields you actually need, and tighten afterwards.</p>
<p>The safest rule is to start with a schema and <code>safeParse</code>, and add each subsequent layer only when a real failure justifies it. Every technique in this article came from a specific production incident rather than from a design document.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The gap between a working demo and a reliable feature is almost entirely in this layer. The model isn't the hard part any more, and the prompt isn't usually the hard part either. The hard part is deciding what you'll accept, detecting when you didn't get it, and responding sensibly when that happens.</p>
<p>Three ideas carry most of the weight:</p>
<ol>
<li><p><strong>The schema is the contract, and everything derives from it.</strong> One definition producing your type, your API schema, and your validator means those three can never drift apart.</p>
</li>
<li><p><strong>Separate syntax failures, schema failures, and semantic failures.</strong> They have different causes and different fixes, and a retry loop that treats them identically will waste money on problems that repeating the call can't solve.</p>
</li>
<li><p><strong>Make fields nullable rather than optional.</strong> Forcing the model to say "this was not present" rather than allowing it to quietly omit the field converts a whole class of silent hallucination into an explicit, checkable value.</p>
</li>
</ol>
<p>Get those right and the rest is ordinary engineering. You're writing validation code and retry logic, which developers have been doing against unreliable inputs for decades. A language model is just a new kind of unreliable input, and it responds well to the same discipline.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://zod.dev">Zod</a>: the schema library used throughout these examples.</p>
</li>
<li><p><a href="https://github.com/StefanTerdell/zod-to-json-schema">zod-to-json-schema</a>: converts a Zod schema into the JSON Schema an API expects.</p>
</li>
<li><p><a href="https://docs.claude.com/en/docs/agents-and-tools/tool-use/overview">Anthropic tool use documentation</a>: forcing structured arguments through a tool definition.</p>
</li>
<li><p><a href="https://platform.openai.com/docs/guides/structured-outputs">OpenAI structured outputs</a>: strict schema conformance through constrained decoding.</p>
</li>
<li><p><a href="https://github.com/dottxt-ai/outlines">Outlines</a>: grammar-constrained generation for locally hosted models.</p>
</li>
<li><p><a href="https://github.com/beenotung/best-effort-json-parser">best-effort-json-parser</a>: parsing incomplete JSON while a response is still streaming.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Agent with Per-User OAuth Access [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ When your AI agent serves more than one person, every tool call must answer: who's the agent acting for? Let's learn how to solve this by building an AI agent that connects with Slack and GitHub. A Sl ]]>
                </description>
                <link>https://www.freecodecamp.org/news/ai-agent-per-user-oauth-slack-github/</link>
                <guid isPermaLink="false">6a7c95758a35a7792fd567c3</guid>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tool calling ]]>
                    </category>
                
                    <category>
                        <![CDATA[ oauth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Saif Ali Shaik ]]>
                </dc:creator>
                <pubDate>Wed, 12 Aug 2026 15:47:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/889cf8c8-41f9-4dec-aa5f-128cb24f0082.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When your AI agent serves more than one person, every tool call must answer: who's the agent acting for? Let's learn how to solve this by building an AI agent that connects with Slack and GitHub.</p>
<p>A Slack read uses that user's workspace. A GitHub issue is created as that user, in a repository they can access. An agent can make the wrong call, but it must never act with the wrong user's access.</p>
<p>The fix has two parts, and both appear in the first half of this tutorial:</p>
<ol>
<li><p><strong>Each user grants access separately.</strong> Alice authorizes Slack for herself. Bob authorizes it for himself.</p>
</li>
<li><p><strong>Your agent passes an identifier, not a token.</strong> A string like <code>alice@example.com</code> selects whose grant to use. One function turns it into a token at the moment of the call, and that token never reaches your model inputs, your tool schemas, or your logs.</p>
</li>
</ol>
<p>Most agent tutorials stop before either point. They hand you an API key, wire up one function, and the model calls it. The design works until a second person shows up.</p>
<p>To make the pattern concrete, you'll build a command-line agent that watches a Slack channel, decides on its own which messages describe real work, files a GitHub issue for those, and replies in the Slack thread with the issue link. Every call runs as one user's own OAuth grant.</p>
<p>You'll write the OAuth flow yourself: the consent redirect, the <code>state</code> check, the token exchange, an encrypted store, and the refresh path. None of it is long, and seeing it whole is what makes the identity argument checkable instead of a claim you take on faith.</p>
<p>Two topics stay out of scope here: we won't cover Model Context Protocol servers or voice or realtime hosts. The identity pattern holds in both settings, but the surrounding plumbing deserves its own article.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-are-ai-agent-tools">What Are AI Agent Tools?</a></p>
</li>
<li><p><a href="#heading-why-a-shared-token-breaks">Why a Shared Token Breaks</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-how-to-register-the-slack-and-github-oauth-apps">How to Register the Slack and GitHub OAuth Apps</a></p>
</li>
<li><p><a href="#heading-how-to-run-the-consent-flow">How to Run the Consent Flow</a></p>
</li>
<li><p><a href="#heading-how-to-store-tokens-encrypted-keyed-by-user">How to Store Tokens Encrypted, Keyed by User</a></p>
</li>
<li><p><a href="#heading-how-to-run-tool-calls-as-the-current-user">How to Run Tool Calls as the Current User</a></p>
</li>
<li><p><a href="#heading-how-to-handle-refresh-and-revocation">How to Handle Refresh and Revocation</a></p>
</li>
<li><p><a href="#heading-how-to-add-a-second-provider">How to Add a Second Provider</a></p>
</li>
<li><p><a href="#heading-full-walkthrough">Full Walkthrough</a></p>
</li>
<li><p><a href="#heading-how-to-apply-the-pattern-to-other-use-cases">How to Apply the Pattern to Other Use Cases</a></p>
</li>
<li><p><a href="#heading-what-went-wrong-when-i-built-this">What Went Wrong When I Built This</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>The agent is called <code>channel-watcher-agent</code>. Each run does four things:</p>
<ol>
<li><p>Reads recent messages from a Slack channel.</p>
</li>
<li><p>Asks a model, message by message, whether the text describes a bug or a concrete action item.</p>
</li>
<li><p>Files a GitHub issue for the messages that qualify.</p>
</li>
<li><p>Replies in the original Slack thread with a link to the new issue.</p>
</li>
</ol>
<p><strong>Nobody clicks a button to start any of it.</strong> Slack already ships a "create an issue from this message" action, which is a different product. Here the agent reads the channel, forms its own judgment, and acts only on what it judges worth acting on.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5d426742d3ccd88c5676d4a2/cb98e59f-ac1a-48c7-9ab3-46a49f78af58.png" alt="Example of the tool in action" style="display: block;" width="1014" height="1028" loading="lazy">

<p>The stack stays small on purpose:</p>
<table>
<thead>
<tr>
<th>Piece</th>
<th>Role</th>
</tr>
</thead>
<tbody><tr>
<td>Node.js, plain ES modules</td>
<td>No web framework, no queue</td>
</tr>
<tr>
<td><code>node:http</code></td>
<td>The OAuth callback server</td>
</tr>
<tr>
<td><code>node:crypto</code></td>
<td>Token encryption</td>
</tr>
<tr>
<td><code>node:sqlite</code></td>
<td>The token store, with no dependency to install</td>
</tr>
<tr>
<td><a href="https://ai-sdk.dev/">Vercel AI SDK</a></td>
<td>The model call and the tool loop</td>
</tr>
</tbody></table>
<p>Three of those five ship with Node. The only packages you install are the AI SDK and its friends.</p>
<p>By the end you'll have:</p>
<ul>
<li><p>Two OAuth apps, Slack and GitHub, that a user consents to once.</p>
</li>
<li><p>An encrypted token store keyed by user and provider.</p>
</li>
<li><p>An agent that resolves the current user to an identifier and never lets a token reach the model.</p>
</li>
<li><p>A tool loop where the model decides whether to file an issue at all.</p>
</li>
<li><p>A demonstration that a second user's run stops instead of reading the first user's data.</p>
</li>
</ul>
<p>The finished code lives at <a href="https://github.com/saif-shines/channel-watcher-agent">github.com/saif-shines/channel-watcher-agent</a>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Accounts and tools:</p>
<ul>
<li><p><strong>Node.js 22.13 or newer</strong>, plus npm. The token store uses <a href="https://nodejs.org/api/sqlite.html"><code>node:sqlite</code></a>, which is stable from that version on.</p>
</li>
<li><p><strong>A Slack workspace</strong> where you can install apps, and a channel to watch. A throwaway channel works best.</p>
</li>
<li><p><strong>A GitHub account</strong> and a repository that can absorb test issues.</p>
</li>
<li><p><strong>An API key for a model provider</strong> the AI SDK supports. Anthropic is used in the examples.</p>
</li>
<li><p><a href="https://github.com/FiloSottile/mkcert"><strong>mkcert</strong></a>, to issue a local HTTPS certificate. <a href="#heading-how-to-register-the-slack-and-github-oauth-apps">How to Register the Slack and GitHub OAuth Apps</a> explains why an ordinary <code>http://localhost</code> callback will not do.</p>
</li>
</ul>
<p>Useful background, though none of it is a hard requirement:</p>
<ul>
<li><p><code>async</code> and <code>await</code>, and reading a small Node script.</p>
</li>
<li><p>OAuth 2.0 at a high level: an app redirects a user to a provider, the user consents, the app receives a token.</p>
</li>
<li><p>Tool calling, sometimes called function calling. The next section covers what the tutorial needs.</p>
</li>
</ul>
<p><strong>One warning before starting:</strong> The agent writes to real systems. It opens real GitHub issues and posts real Slack messages. Use a test Slack channel and a throwaway GitHub repository while you're still checking that it only acts on messages you intend.</p>
<h2 id="heading-what-are-ai-agent-tools">What Are AI Agent Tools?</h2>
<p><strong>A tool is a function you hand the model along with your input.</strong> The model can't run that function itself. It can only ask: call <code>fileGithubIssue</code> with this title and this body. Your code performs the call, returns the result, and the model uses that result to choose the next step.</p>
<p>Request, execute, return. The exchange is the whole mechanism, and everything called an "agent" is a loop around it.</p>
<h3 id="heading-how-a-tool-differs-from-an-api">How a Tool Differs from an API</h3>
<p>Tools and APIs wrap the same call but are written for different readers.</p>
<p>An API is written for you. It assumes you read the documentation, and that you know <code>thread_ts</code> is the field that turns a Slack message into a threaded reply.</p>
<p>A tool is written for a model that has read nothing. So a tool carries its own explanation:</p>
<ul>
<li><p>A <strong>name</strong> the model can reason about, like <code>fileGithubIssue</code>.</p>
</li>
<li><p>A <strong>description</strong> in plain language, including when not to use the tool.</p>
</li>
<li><p>A <strong>schema</strong> for the inputs, so the model knows <code>title</code> is a required string.</p>
</li>
</ul>
<p>Below is one tool from the project. Most of the code is explanation rather than logic:</p>
<pre><code class="language-javascript">const fileGithubIssue = tool({
  description: 'File a GitHub issue for an actionable Slack message',
  inputSchema: z.object({
    title: z.string(),
    body: z.string(),
  }),
  execute: async ({ title, body }) =&gt; {
    // ... the actual API call goes here
  },
});
</code></pre>
<p>The <code>description</code> and <code>inputSchema</code> are the parts the model sees. The <code>execute</code> function is yours alone. Identity gets settled inside <code>execute</code>, so the model never learns which account the call ran against.</p>
<h3 id="heading-why-models-handle-tools-better-than-raw-api-calls">Why Models Handle Tools Better Than Raw API Calls</h3>
<p>Pasting a curl command into the input and asking the model to fill in the blanks is possible. But this approach fails in predictable ways.</p>
<p>Tools work better for three reasons:</p>
<ol>
<li><p>The schema is enforced before your code runs. A malformed tool call gets rejected and retried by the SDK. A malformed URL fails at runtime instead.</p>
</li>
<li><p>Results return to the model. After <code>fileGithubIssue</code> returns, the model can read the new issue URL and use it in the Slack reply. The chaining is what makes the second step possible.</p>
</li>
<li><p>Credentials stay out of the conversation. The model asks for an action by name and never sees a token. A token it never sees can't leak into a completion, a log line, or a prompt-injection payload.</p>
</li>
</ol>
<p>Reason three is what the rest of this tutorial builds toward. You'll keep tokens out of the model on purpose: the agent holds an identifier, and a token appears only at the moment of the provider call.</p>
<h3 id="heading-most-agents-need-more-than-one-app">Most Agents Need More Than One App</h3>
<p>Few useful agents talk to a single app. A support agent reads Zendesk and updates Salesforce. A standup agent reads GitHub and posts to Slack. A scheduling agent reads Gmail and writes to Google Calendar.</p>
<p>Each app brings its own OAuth registration, scope names, token lifetime, and refresh behavior. Multiply the list by every user of the agent, and the real problem appears.</p>
<h2 id="heading-why-a-shared-token-breaks">Why a Shared Token Breaks</h2>
<p>One shared credential for everybody works in a demo and fails once a second person shows up. Picture the quick version of the Slack half: create a Slack app, install it, copy the bot token into <code>.env</code>, and let every tool call use it.</p>
<p>Three problems arrive together.</p>
<p>First, every run uses the same permissions. The bot sees every channel it was invited to, no matter who triggered the run. Ask the agent about a channel you were never in, and the bot reads it anyway. The agent has become a way around your own workspace permissions.</p>
<p>Second, the audit trail is also wrong. Every GitHub issue says the bot opened it. Every Slack reply comes from the bot. Asked why an issue exists, the honest answer is "an agent filed it for somebody, and we can't tell who."</p>
<p>And third, revocation stops working. A user leaves the company and their Slack account is deactivated. The agent keeps running, because it never used their credentials.</p>
<p>The alternative is per-user grants. Each user authorizes the apps for themselves. That creates a new requirement, though: somewhere to keep those grants.</p>
<h3 id="heading-the-distinction-is-one-field-in-one-response">The Distinction is One Field in One Response</h3>
<p>Slack makes the difference unusually easy to see. When a user finishes the consent screen, <a href="https://docs.slack.dev/authentication/installing-with-oauth">the token exchange</a> returns both kinds of token in the same JSON object:</p>
<pre><code class="language-json">{
  "ok": true,
  "access_token": "xoxb-REDACTED-BOT-TOKEN",
  "token_type": "bot",
  "authed_user": {
    "id": "U0A1B2C3D",
    "scope": "channels:history,chat:write,users:read",
    "access_token": "xoxp-REDACTED-USER-TOKEN",
    "token_type": "user"
  }
}
</code></pre>
<p>The top-level <code>access_token</code> is the bot. The nested <code>authed_user.access_token</code> is the person who just consented. Reading <code>conversations.history</code> with the first one returns every channel the app was invited to. Reading it with the second returns only the channels that users can already see. The same split governs writes: <a href="https://docs.slack.dev/reference/methods/chat.postMessage"><code>chat.postMessage</code></a> with a user token posts under that person's name.</p>
<p>Two fields, one letter apart in the prefix, and the entire permission model of your agent hangs on which one you store. This tutorial requests only user scopes, so Slack issues no bot token at all.</p>
<h3 id="heading-tokens-must-stay-out-of-the-model-and-the-logs">Tokens Must Stay Out of the Model and the Logs</h3>
<p>Per-user tokens become the most sensitive data in the system. Two destinations are off limits:</p>
<ul>
<li><p><strong>The model:</strong> Keep tokens out of inputs, tool descriptions, and tool return values. A model that has seen a token can repeat it, and prompt injection turns any tool result into untrusted input.</p>
</li>
<li><p><strong>Your logs:</strong> Tool inputs and outputs are exactly what you want to log while debugging an agent. Tokens traveling in those payloads land in your log store permanently.</p>
</li>
</ul>
<p>This tutorial keeps tokens on one narrow path. Your code passes an identifier, a stable reference to one user. One helper turns that identifier into a token, and from there the token goes straight into a provider call and nowhere else. It's never named in a tool schema, never attached to anything the model can read, and never returned from a tool.</p>
<h3 id="heading-why-you-own-the-oauth-apps-and-the-store">Why You Own the OAuth Apps and the Store</h3>
<p>The point of writing the flow yourself isn't the plumbing. It's control over who may use whose grant.</p>
<p>In this tutorial the users are teammates. Each person connects their own Slack and GitHub, and the agent acts as whoever triggered the run. The same design holds when those users are customers of your product: each person still has their own grant, and a wrong mapping means one person's run using someone else's access. Only the source of the identifier changes. A session for teammates, a tenant record for customers.</p>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<p>Two flows matter, and they happen at different times. Keeping them separate is most of the work.</p>
<p>Connection time happens once per user, per app. The user consents, and tokens land in your store. The agent isn't running.</p>
<p>Runtime happens on every execution. The agent resolves the current user to an identifier and does its work. No consent screens and no browser.</p>
<pre><code class="language-text">CONNECTION TIME (once per user, per app)

  Your user              connect.js              Slack / GitHub
     |                       |                         |
     |-- "connect Slack" ---&gt;|                         |
     |&lt;--- consent link -----|                         |
     |----------------------- OAuth consent ----------&gt;|
     |                       |&lt;--- redirect + code ----|
     |                       |---- exchange code -----&gt;|
     |                       |&lt;---- tokens ------------|
     |                       |                         |
     |                  [encrypt, store                |
     |                   under (identifier,            |
     |                   provider)]                    |
     |                       |                         |


RUNTIME (every agent run)

  Your agent             Token store             Slack / GitHub
     |                       |                         |
  [resolve identifier        |                         |
   from your own session]    |                         |
     |                       |                         |
     |-- getAccessToken( ---&gt;|                         |
     |     identifier,       |                         |
     |     provider )        |                         |
     |&lt;---- token -----------|                         |
     |                       |                         |
     |------------------ API call as user ------------&gt;|
     |&lt;----------------- result -----------------------|
     |                       |                         |
  [model sees result,        |                         |
   never a token]            |                         |
</code></pre>
<p>Three properties follow from the shape.</p>
<p>The identifier replaces the token in your agent code. Everything above the token store handles a string like <code>alice@example.com</code> or <code>user_8f21c</code>. The string is worthless on its own: without the store and its encryption key, it opens nothing.</p>
<p>One identity spans many apps. A single identifier has a Slack row and a GitHub row beneath it. A third app doesn't create a third identity to reconcile.</p>
<p>Authorization stays in your code. The store answers which tokens belong to an identifier. The store can't know whether the request deserved an answer. Deciding that the caller may act as that identifier happens before any call.</p>
<p>One rule follows, and bending it defeats the whole design: resolve the identifier server-side from an authenticated session. Never accept an identifier from a request body, a query parameter, or a browser. An identifier accepted from a client is an "act as any user" endpoint.</p>
<h2 id="heading-how-to-register-the-slack-and-github-oauth-apps">How to Register the Slack and GitHub OAuth Apps</h2>
<p>The walkthrough uses Slack and GitHub as the two providers end to end. Both need the same three things: a registered app, a redirect URI, and a set of scopes. The details differ enough to be worth walking through separately.</p>
<h3 id="heading-the-redirect-uri-has-to-use-https">The Redirect URI Has to Use HTTPS</h3>
<p>Most tutorials that touch OAuth hand you <code>http://localhost:3000/callback</code> and move on. Slack rejects it. <a href="https://docs.slack.dev/authentication/installing-with-oauth">Slack's documentation</a> states flatly that "a Redirect URL must also use HTTPS", and it makes no exception for <code>localhost</code>. GitHub is more relaxed and accepts either, so a single HTTPS callback satisfies both.</p>
<p>The rule looks pedantic, because on <code>localhost</code> the request never leaves your machine and there's nothing on the wire to intercept. Slack applies it uniformly anyway, and a uniform rule with no exemptions is a defensible choice for a provider handing out credentials: every exemption is a branch somebody has to get right, and "is this really localhost" is a question that has been answered incorrectly before.</p>
<p><a href="https://github.com/FiloSottile/mkcert">mkcert</a> issues a certificate signed by a local authority it adds to your system trust store, so the browser accepts it without a warning:</p>
<pre><code class="language-bash">mkcert -install
mkcert localhost
</code></pre>
<p>That writes <code>localhost.pem</code> and <code>localhost-key.pem</code> into the current directory. A tunneling service such as ngrok also works, but its free URLs rotate, which means re-editing both app registrations every session.</p>
<h3 id="heading-the-slack-app-and-the-one-setting-that-matters">The Slack App, and the One Setting That Matters</h3>
<p>At <a href="https://api.slack.com/apps">api.slack.com/apps</a>, create an app in your workspace. Then open <strong>OAuth &amp; Permissions</strong> and set two things.</p>
<p>Add <code>https://localhost:3000/callback</code> under <strong>Redirect URLs</strong>.</p>
<p>Then find the scopes. The page has two sections, and choosing the wrong one silently rebuilds the shared-bot design:</p>
<table>
<thead>
<tr>
<th>Section</th>
<th>What it grants</th>
<th>Use it here?</th>
</tr>
</thead>
<tbody><tr>
<td>Bot Token Scopes</td>
<td>A <code>xoxb-</code> token that acts as the app</td>
<td>No</td>
</tr>
<tr>
<td>User Token Scopes</td>
<td>A <code>xoxp-</code> token that acts as the person</td>
<td>Yes</td>
</tr>
</tbody></table>
<p>Under <strong>User Token Scopes</strong>, add:</p>
<ul>
<li><p><code>channels:history</code>: read messages in public channels the user belongs to</p>
</li>
<li><p><code>chat:write</code>: post as the user</p>
</li>
<li><p><code>users:read</code>: turn user IDs into names</p>
</li>
</ul>
<p>Leave Bot Token Scopes empty. Copy the Client ID and Client Secret from <strong>Basic Information</strong>.</p>
<h3 id="heading-the-github-oauth-app">The GitHub OAuth App</h3>
<p>Under Settings → Developer settings → OAuth Apps → New OAuth App, set the Authorization callback URL to the same <code>https://localhost:3000/callback</code>, then generate a client secret. GitHub documents <a href="https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps">the web application flow</a> in full if you want the surrounding detail.</p>
<p>GitHub's <a href="https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps">scope</a> for issue creation depends on the repository:</p>
<ul>
<li><p><code>repo</code> covers private repositories, and grants read and write access to code along with it.</p>
</li>
<li><p><code>public_repo</code> is the narrower choice, and enough when your test repository is public.</p>
</li>
</ul>
<p>Take the narrower one when you can. A scope you didn't need is a scope you have to explain later.</p>
<h3 id="heading-the-environment-file">The Environment File</h3>
<p>Both apps produce a client ID and a client secret, and the store needs an encryption key. Generate the key first:</p>
<pre><code class="language-bash">node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
</code></pre>
<p>Then fill in <code>.env</code>:</p>
<pre><code class="language-bash">OAUTH_REDIRECT_URI=https://localhost:3000/callback
TLS_CERT_PATH=./localhost.pem
TLS_KEY_PATH=./localhost-key.pem

SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

TOKEN_ENCRYPTION_KEY=

SLACK_CHANNEL_ID=C0XXXXXXXXX
GITHUB_REPO=your-name/your-test-repo
</code></pre>
<p>Those client secrets authenticate <strong>your application</strong> to the providers. They're not user credentials, and they never belong in a browser.</p>
<h2 id="heading-how-to-run-the-consent-flow">How to Run the Consent Flow</h2>
<p>Everything provider-specific belongs in one place, so that adding a third provider later means adding an entry rather than a branch.</p>
<h3 id="heading-step-1-describe-each-provider-once">Step 1: Describe Each Provider Once</h3>
<pre><code class="language-javascript">const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;

export const providers = {
  slack: {
    label: 'Slack',
    authorizeUrl: 'https://slack.com/oauth/v2/authorize',
    tokenUrl: 'https://slack.com/api/oauth.v2.access',

    // These go in `user_scope`, not `scope`. Scopes listed under `scope` grant
    // a bot token, and a bot token is what this project exists to avoid.
    userScopes: ['channels:history', 'chat:write', 'users:read'],

    buildAuthorizeUrl(state) {
      const url = new URL(this.authorizeUrl);
      url.searchParams.set('client_id', process.env.SLACK_CLIENT_ID);
      url.searchParams.set('user_scope', this.userScopes.join(','));
      url.searchParams.set('redirect_uri', REDIRECT_URI);
      url.searchParams.set('state', state);
      return url.toString();
    },
    // exchangeCode and refresh follow below
  },
};
</code></pre>
<p><strong>The</strong> <code>user_scope</code> <strong>parameter is the whole argument in one line.</strong> Slack reads <code>scope</code> for bot permissions and <code>user_scope</code> for user permissions. This project sets only the second, so the response comes back with no bot token in it at all.</p>
<p>The <code>state</code> parameter isn't optional. It's a random string you generate, send to the provider, and check on the way back. Without it, any page on the internet can point a browser at your callback URL with an attacker's <code>code</code> attached, and your server will happily exchange it and store the attacker's token under your user's identifier.</p>
<h3 id="heading-step-2-exchange-the-code-and-take-the-right-token">Step 2: Exchange the Code, and Take the Right Token</h3>
<pre><code class="language-javascript">async exchangeCode(code) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      client_id: process.env.SLACK_CLIENT_ID,
      client_secret: process.env.SLACK_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });

  const json = await response.json();

  // Slack answers HTTP 200 even when the exchange failed. The `ok` field
  // is the real status.
  if (!json.ok) {
    throw new Error(`Slack token exchange failed: ${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user);
}
</code></pre>
<p>Two details in that function cost real debugging time when missed.</p>
<p><strong>Slack returns HTTP 200 for failures.</strong> Checking <code>response.ok</code> tells you the HTTP request succeeded, which it did. The <code>json.ok</code> field is the one that reports whether the OAuth exchange worked.</p>
<p><code>json.authed_user</code><strong>, not</strong> <code>json</code><strong>.</strong> This is the fork from the section above, expressed as one property access. Reading <code>json.access_token</code> here would compile, run, store a token, and quietly give every user of your agent the same bot identity.</p>
<p>Normalizing the result keeps the rest of the codebase provider-agnostic:</p>
<pre><code class="language-javascript">function normalizeSlackTokens(authedUser) {
  return {
    accessToken: authedUser.access_token,
    refreshToken: authedUser.refresh_token ?? null,
    expiresAt: authedUser.expires_in
      ? Date.now() + authedUser.expires_in * 1000
      : null,
    scope: authedUser.scope,
  };
}
</code></pre>
<p>GitHub's version of the same function differs in two ways worth noting:</p>
<pre><code class="language-javascript">async exchangeCode(code) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    // Without this header GitHub answers with a form-encoded body.
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      Accept: 'application/json',
    },
    body: new URLSearchParams({
      code,
      client_id: process.env.GITHUB_CLIENT_ID,
      client_secret: process.env.GITHUB_CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });

  const json = await response.json();
  if (json.error) {
    throw new Error(
      `GitHub token exchange failed: ${json.error_description ?? json.error}`
    );
  }

  // OAuth App tokens carry no expiry, so there is nothing to refresh.
  return {
    accessToken: json.access_token,
    refreshToken: null,
    expiresAt: null,
    scope: json.scope,
  };
}
</code></pre>
<p>The <code>Accept: application/json</code> header is easy to skip and produces a confusing failure: <code>response.json()</code> throws on a body that came back as <code>access_token=gho_...&amp;scope=repo</code>.</p>
<h3 id="heading-step-3-catch-the-redirect">Step 3: Catch the Redirect</h3>
<p>OAuth needs somewhere to land. For a command-line tool, a server that starts, handles one callback per provider, and exits is enough. Because Slack demands HTTPS, the scheme in <code>OAUTH_REDIRECT_URI</code> decides which kind of server to start:</p>
<pre><code class="language-javascript">function createCallbackServer(handler) {
  if (redirect.protocol !== 'https:') {
    return createHttpServer(handler);
  }

  try {
    return createHttpsServer(
      {
        cert: readFileSync(process.env.TLS_CERT_PATH),
        key: readFileSync(process.env.TLS_KEY_PATH),
      },
      handler
    );
  } catch (err) {
    throw new Error(
      `Could not read the TLS certificate (${err.code ?? err.message}).\n` +
        'Generate a locally-trusted one with mkcert:\n' +
        '  mkcert -install\n' +
        '  mkcert localhost\n' +
        'then point TLS_CERT_PATH and TLS_KEY_PATH at the two files it writes.'
    );
  }
}
</code></pre>
<p>A missing certificate is going to happen to somebody, and <code>ENOENT</code> on its own explains nothing about OAuth. The catch block spends four lines saying what to run instead.</p>
<p>The handler itself is where <code>state</code> gets checked:</p>
<pre><code class="language-javascript">const pending = new Map();

function handleCallback(request, response) {
  const url = new URL(request.url, redirect.origin);

  if (url.pathname !== redirect.pathname) {
    response.writeHead(404).end('Not found');
    return;
  }

  const state = url.searchParams.get('state');
  const entry = pending.get(state);

  if (!entry) {
    response.writeHead(400).end('State mismatch. Start the flow again.');
    return;
  }

  pending.delete(state);

  const error = url.searchParams.get('error');
  if (error) {
    response.writeHead(400).end(`Authorization denied: ${error}`);
    entry.reject(new Error(`[${entry.provider}] authorization denied: ${error}`));
    return;
  }

  entry.finish(url.searchParams.get('code'), response);
}
</code></pre>
<p><strong>The</strong> <code>pending</code> <strong>map is the</strong> <code>state</code> <strong>check.</strong> A state value gets into that map only when this process generated it, and it's deleted the moment it's used. An unrecognized state means the callback didn't come from a flow you started, and a state that arrives twice means a replay. Both fall out of one <code>Map</code> lookup.</p>
<p>Generating the state and waiting for its callback:</p>
<pre><code class="language-javascript">function connect(providerName) {
  const provider = providers[providerName];
  const state = randomBytes(16).toString('hex');

  console.log(`\n[${providerName}] authorize as "${IDENTIFIER}":`);
  console.log(provider.buildAuthorizeUrl(state));

  return new Promise((resolve, reject) =&gt; {
    pending.set(state, {
      provider: providerName,
      reject,
      async finish(code, response) {
        const tokens = await provider.exchangeCode(code);
        saveGrant(IDENTIFIER, providerName, tokens);
        response
          .writeHead(200, { 'Content-Type': 'text/html' })
          .end(`&lt;p&gt;${provider.label} connected. You can close this tab.&lt;/p&gt;`);
        resolve();
      },
    });
  });
}
</code></pre>
<p><code>randomBytes(16)</code> and not <code>Math.random()</code>. A predictable state parameter is the same as no state parameter.</p>
<p>Running it walks each unconnected provider in turn:</p>
<pre><code class="language-text">[slack] authorize as "alice@example.com":
https://slack.com/oauth/v2/authorize?client_id=123.456&amp;user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&amp;redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&amp;state=1159699dbf1a808fd33ba31c7b643505
</code></pre>
<p>Notice what that URL doesn't contain: any <code>scope</code> parameter. Slack has no instruction to mint a bot token, so it won't.</p>
<h2 id="heading-how-to-store-tokens-encrypted-keyed-by-user">How to Store Tokens Encrypted, Keyed by User</h2>
<p>The store answers one question: which token belongs to this user, for this provider? Everything else about it follows from keeping that answer safe.</p>
<p><code>node:sqlite</code> has shipped with Node since v22.5, and stopped requiring a flag in v22.13. That makes a real database available with nothing to install:</p>
<pre><code class="language-javascript">import { DatabaseSync } from 'node:sqlite';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY ?? '', 'base64');

if (KEY.length !== 32) {
  throw new Error(
    'TOKEN_ENCRYPTION_KEY must be 32 bytes, base64-encoded. ' +
      `Got ${KEY.length} bytes.`
  );
}

const db = new DatabaseSync(
  process.env.TOKEN_DB_PATH ?? new URL('../tokens.db', import.meta.url).pathname
);

// One row per user, per provider. expires_at stays outside the ciphertext so
// a token's freshness can be checked without decrypting it.
db.exec(`
  CREATE TABLE IF NOT EXISTS grants (
    identifier TEXT    NOT NULL,
    provider   TEXT    NOT NULL,
    ciphertext BLOB    NOT NULL,
    iv         BLOB    NOT NULL,
    auth_tag   BLOB    NOT NULL,
    expires_at INTEGER,
    PRIMARY KEY (identifier, provider)
  )
`);
</code></pre>
<p><strong>The composite primary key is the isolation guarantee, written down.</strong> <code>(identifier, provider)</code> means Alice's Slack row and Bob's Slack row can't collide, and no query that supplies both parts can return somebody else's grant.</p>
<p><code>expires_at</code> <strong>sits outside the ciphertext deliberately.</strong> Checking whether a token needs refreshing is something you do before every call. Decrypting to find out would mean decrypting constantly, so the one field that isn't a secret stays readable.</p>
<p>Encryption is AES-256-GCM, which authenticates as well as encrypts:</p>
<pre><code class="language-javascript">function encrypt(payload) {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', KEY, iv);
  const ciphertext = Buffer.concat([
    cipher.update(JSON.stringify(payload), 'utf8'),
    cipher.final(),
  ]);
  return { ciphertext, iv, authTag: cipher.getAuthTag() };
}

function decrypt({ ciphertext, iv, authTag }) {
  const decipher = createDecipheriv('aes-256-gcm', KEY, iv);
  decipher.setAuthTag(authTag);
  const plaintext = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final(),
  ]);
  return JSON.parse(plaintext.toString('utf8'));
}
</code></pre>
<p>Three rules govern that pair, and breaking any one of them is worse than not encrypting at all, because it looks like it worked:</p>
<ol>
<li><p><strong>A fresh IV per encryption:</strong> Reusing an initialization vector with GCM is a catastrophic failure, not a minor one. <code>randomBytes(12)</code> on every call, stored beside the ciphertext.</p>
</li>
<li><p><strong>Keep the auth tag:</strong> GCM produces a tag that proves the ciphertext wasn't altered. Without <code>setAuthTag</code> on the way back, you have encryption without integrity, and <code>decipher.final()</code> won't complain.</p>
</li>
<li><p><strong>Encrypt the whole token object, not each field.</strong> One ciphertext for <code>{ accessToken, refreshToken, scope }</code> means one IV and one tag to manage rather than three of each.</p>
</li>
</ol>
<p>Writing and reading are then unremarkable:</p>
<pre><code class="language-javascript">export function saveGrant(identifier, provider, tokens) {
  const { ciphertext, iv, authTag } = encrypt(tokens);
  db.prepare(
    `INSERT INTO grants (identifier, provider, ciphertext, iv, auth_tag, expires_at)
     VALUES (?, ?, ?, ?, ?, ?)
     ON CONFLICT (identifier, provider) DO UPDATE SET
       ciphertext = excluded.ciphertext,
       iv         = excluded.iv,
       auth_tag   = excluded.auth_tag,
       expires_at = excluded.expires_at`
  ).run(identifier, provider, ciphertext, iv, authTag, tokens.expiresAt ?? null);
}
</code></pre>
<p>The <code>ON CONFLICT</code> clause matters more than it looks. Re-consenting has to replace a grant rather than fail or duplicate it, and re-consenting is exactly what a user does after a revocation or a scope change.</p>
<p>The encryption key itself lives in <code>.env</code> here, which is right for a tutorial and wrong for production, where it belongs in a secrets manager or a KMS. Losing it makes every stored grant unreadable and forces every user to consent again. That is a real outage, but it's a better one than the alternative: a stolen database file that hands over working tokens for every user of your agent.</p>
<h2 id="heading-how-to-run-tool-calls-as-the-current-user">How to Run Tool Calls as the Current User</h2>
<p>Runtime has three moves: resolve the identifier, fetch a token with it, and wrap the whole thing as a tool.</p>
<h3 id="heading-step-1-resolve-the-identifier-then-authorize">Step 1: Resolve the Identifier, Then Authorize</h3>
<p>An identifier is <strong>any stable string</strong> that represents one user, an email address, a user ID, a tenant-scoped key.</p>
<pre><code class="language-javascript">// In a real app this comes from your authenticated session, resolved
// server-side. Never accept it from client input.
const IDENTIFIER = process.argv[2] ?? 'channel-watcher-agent';
</code></pre>
<p>Reading the identifier from <code>argv</code> keeps the demo runnable without a login, and it makes the isolation test later in this tutorial a single command. A real application replaces the line:</p>
<pre><code class="language-javascript">// Real app: resolve from your authenticated session, server-side.
const session = await getSession(request);                  // your auth
const identifier = await lookupIdentifier(session.userId);  // your database
</code></pre>
<p><strong>Order matters in those two lines.</strong> Authenticate the caller first, then look up which identifier the caller may act as. An identifier arriving from a client turns the endpoint into a reader of any user's Slack.</p>
<h3 id="heading-step-2-turn-the-identifier-into-a-token-late">Step 2: Turn the Identifier into a Token, Late</h3>
<p>One function stands between the identifier and every provider call:</p>
<pre><code class="language-javascript">const REFRESH_WINDOW_MS = 60_000;

export async function getAccessToken(identifier, providerName) {
  const grant = readGrant(identifier, providerName);

  if (!grant) {
    throw new Error(
      `[${providerName}] no grant for "${identifier}".\n` +
        `Connect it first: node src/connect.js ${identifier}`
    );
  }

  const expiringSoon =
    grant.expiresAt !== null &amp;&amp;
    grant.expiresAt !== undefined &amp;&amp;
    grant.expiresAt - Date.now() &lt; REFRESH_WINDOW_MS;

  if (!expiringSoon) {
    return grant.accessToken;
  }

  if (!grant.refreshToken) {
    throw new Error(
      `[${providerName}] token for "${identifier}" expired and no refresh ` +
        'token is stored. The user has to consent again.'
    );
  }

  const refreshed = await providers[providerName].refresh(grant.refreshToken);
  saveGrant(identifier, providerName, refreshed);
  return refreshed.accessToken;
}
</code></pre>
<p><strong>Call this immediately before the API call, not once at startup.</strong> A long agent run can outlive a twelve-hour token, and resolving tokens up front means discovering that at the least convenient moment. Fetching late costs one cheap database read and removes the whole class of problem.</p>
<p>Also, the <strong>sixty-second window isn't padding for its own sake.</strong> A token with four seconds left passes a naive expiry check and then expires in flight. Refreshing anything inside the window means the token handed back is good for at least a minute of work.</p>
<p>Finally, a missing grant raises an error rather than falling back. There's nothing sensible to fall back to. The correct outcome for an unconnected user is a stop, with a message saying how to connect.</p>
<h3 id="heading-step-3-wrap-provider-calls-as-tools">Step 3: Wrap Provider Calls as Tools</h3>
<p>Identity gets injected here, one layer below anything the model can influence:</p>
<pre><code class="language-javascript">export function buildTools(identifier) {
  const [owner, repo] = process.env.GITHUB_REPO.split('/');

  const fileGithubIssue = tool({
    description: 'File a GitHub issue for an actionable Slack message',
    inputSchema: z.object({
      title: z.string(),
      body: z.string(),
    }),
    execute: async ({ title, body }) =&gt; {
      const token = await getAccessToken(identifier, 'github');
      return createIssue(token, owner, repo, { title, body });
    },
  });

  const replyInSlackThread = tool({
    description:
      'Reply in the original Slack thread (e.g. with the created issue link)',
    inputSchema: z.object({
      text: z.string(),
      thread_ts: z.string(),
    }),
    execute: async ({ text, thread_ts }) =&gt; {
      const token = await getAccessToken(identifier, 'slack');
      return postThreadReply(
        token,
        process.env.SLACK_CHANNEL_ID,
        text,
        thread_ts
      );
    },
  });

  return { fileGithubIssue, replyInSlackThread };
}
</code></pre>
<p>Compare what the model controls against what it can't. The model chooses <code>title</code>, <code>body</code>, and <code>text</code>. <strong>The model can't choose the user.</strong> <code>identifier</code> is a closure argument, fixed before the model ran, and it appears in no <code>inputSchema</code>. There's no input that makes the model file an issue as somebody else, because the account isn't one of its inputs.</p>
<p>Return values deserve one audit each. <code>createIssue</code> returns the issue number, URL, and title. <code>postThreadReply</code> returns a timestamp. Neither returns a token, and neither returns the raw provider response, which is where a token would hide if one were going to.</p>
<p>The provider calls themselves are ordinary HTTP:</p>
<pre><code class="language-javascript">export async function createIssue(token, owner, repo, { title, body }) {
  const response = await fetch(
    `https://api.github.com/repos/${owner}/${repo}/issues`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/vnd.github+json',
        'X-GitHub-Api-Version': '2022-11-28',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title, body }),
    }
  );

  const json = await response.json();

  if (!response.ok) {
    // 403 here usually means the grant is missing the `repo` scope.
    throw new Error(
      `GitHub issue creation failed (${response.status}): ${json.message}`
    );
  }

  return { number: json.number, url: json.html_url, title: json.title };
}
</code></pre>
<h3 id="heading-step-4-read-the-channel">Step 4: Read the Channel</h3>
<p>Slack's <a href="https://docs.slack.dev/reference/methods/conversations.history"><code>conversations.history</code></a> returns clean JSON, with one gap: messages carry a user ID, never a display name. Turning those into names means a <a href="https://docs.slack.dev/reference/methods/users.info"><code>users.info</code></a> call each, which is what <code>users:read</code> was in the scope list for.</p>
<pre><code class="language-javascript">export async function readChannel(token, channelId, limit = 20) {
  const { messages } = await slackCall(token, 'conversations.history', {
    channel: channelId,
    limit: String(limit),
  });

  const authors = await resolveAuthors(
    token,
    messages.filter((m) =&gt; m.user).map((m) =&gt; m.user)
  );

  return messages
    .filter((message) =&gt; message.text)
    .map((message) =&gt; ({
      author: authors.get(message.user) ?? 'unknown',
      userId: message.user,
      text: message.text,
      ts: message.ts,
    }))
    .reverse(); // oldest first
}
</code></pre>
<p>Three small decisions in that function:</p>
<ol>
<li><p><strong>Names cost one</strong> <code>users.info</code> <strong>call per unique author.</strong> Caching them per run keeps a channel full of one person's messages from producing twenty identical lookups. A lookup that fails falls back to the user ID rather than throwing, since an unresolvable name isn't a reason to abandon the run.</p>
</li>
<li><p><strong>Messages without</strong> <code>text</code> <strong>get dropped.</strong> Channel joins and purpose changes arrive as message objects with no body, and there's nothing for the model to triage in them.</p>
</li>
<li><p><code>.reverse()</code> <strong>isn't cosmetic.</strong> Slack returns newest first. A model reading a conversation backwards will misread which message answered which.</p>
</li>
</ol>
<p>The <code>ts</code> field then does double duty. It identifies a message, which makes it both the thread anchor for replies and the key for remembering what the agent already handled:</p>
<pre><code class="language-javascript">const state = await loadState();
const processed = new Set(state[IDENTIFIER]?.processedTs ?? []);
const newMessages = messages.filter((m) =&gt; !processed.has(m.ts));
</code></pre>
<p><strong>Key that state by identifier</strong>, as the snippet does. A single flat list lets one user's processed messages hide another's, which reintroduces cross-user bleed in the one place the whole design exists to prevent.</p>
<h3 id="heading-step-5-run-the-tool-loop">Step 5: Run the Tool Loop</h3>
<p>Hand the model both tools and let it decide:</p>
<pre><code class="language-javascript">const { text } = await generateText({
  model: anthropic(process.env.MODEL),
  tools,
  stopWhen: stepCountIs(5),
  prompt: `You triage messages from a dev team's Slack channel.

Message from ${message.author}: "${message.text}"
Message timestamp (thread_ts): ${message.ts}

Decide if this message is actionable (a bug report or concrete action item) or just noise (chit-chat, join notices, already-resolved chatter).

If actionable: file a GitHub issue with a clear title and body drafted from the message, then reply in the original Slack thread (use the exact thread_ts above) with a short note and the created issue's URL.

If not actionable: do nothing and briefly say why.`,
});
</code></pre>
<p>The <strong>loop</strong> is what makes the second step possible. The model reads the message and may call <code>fileGithubIssue</code>. The AI SDK runs the tool, feeds the result back into context along with the new issue URL, and calls the model again. Now the model can reply in the thread with a URL it couldn't have known on the first pass. Then it stops.</p>
<p><code>stopWhen: stepCountIs(5)</code> caps the rounds. Without a bound, a confused model can retry a failing tool indefinitely. Five rounds is generous for two tools.</p>
<p>A deterministic version is also reasonable: classify with a structured-output call, then call both tools yourself in a fixed order when the message qualifies.</p>
<p>The fixed sequence is easier to test and gives up real flexibility. A loop lets the model skip the reply, or file without replying, and adding a third tool needs no new branching. Choose the loop when the set of actions varies per input, and the fixed sequence when it never does.</p>
<p>One note on the provider line, for accuracy about what ran. The snippet above uses <code>@ai-sdk/anthropic</code>, which suits a direct Anthropic API key. My own tests went through an OpenAI-compatible gateway, which changes only the provider construction:</p>
<pre><code class="language-javascript">import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

const gateway = createOpenAICompatible({
  name: 'gateway',
  baseURL: `${process.env.GATEWAY_BASE_URL}/v1`,
  apiKey: process.env.GATEWAY_API_KEY,
});
// then: model: gateway(process.env.MODEL)
</code></pre>
<p>The tools, the loop, and the token handling are identical either way. Only the <code>model</code> argument changes.</p>
<h2 id="heading-how-to-handle-refresh-and-revocation">How to Handle Refresh and Revocation</h2>
<p><strong>Tokens end in two different ways,</strong> and only one of them is your code's problem**.** Expiry is routine and recoverable. Revocation is a decision somebody made, and the correct response is to ask for consent again.</p>
<p>The two providers in this tutorial sit at opposite ends of the range, which makes them a useful pair.</p>
<h3 id="heading-github-tokens-that-dont-expire-until-they-do">GitHub: Tokens That Don't Expire, Until They Do</h3>
<p>An OAuth App user token has no expiry timestamp. There's no refresh token to store and no refresh call to make, which is why <code>github.refresh()</code> in this project does nothing but explain itself:</p>
<pre><code class="language-javascript">async refresh() {
  throw new Error(
    'GitHub OAuth App tokens do not expire. A failure here means the ' +
      'grant was revoked — send the user through consent again.'
  );
}
</code></pre>
<p>"Does not expire" is not the same as "lasts forever," and GitHub <a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation">revokes tokens</a> for several reasons worth knowing:</p>
<ul>
<li><p>The user revokes the authorization from their account settings.</p>
</li>
<li><p>The token goes unused for one year.</p>
</li>
<li><p>The token gets pushed to a public repository or gist, at which point GitHub revokes it automatically.</p>
</li>
<li><p>The app accumulates more than ten tokens for the same user and scope combination, and the oldest are revoked.</p>
</li>
</ul>
<p>The third one deserves a moment. GitHub scans public pushes for its own token formats and kills what it finds. That is a safety net, not a strategy, and the one thing it can't protect is a token in a private repository or a log file.</p>
<p><strong>GitHub Apps behave differently from OAuth Apps</strong>, which is a common source of confusion when reading GitHub's documentation. A GitHub App's user access token expires in eight hours and comes with a refresh token good for six months. If you build on GitHub Apps instead, the Slack-shaped refresh path below is the one you want.</p>
<h3 id="heading-slack-rotation-is-opt-in-and-permanent">Slack: Rotation is Opt-in and Permanent</h3>
<p>By default, a Slack user token doesn't expire either. <a href="https://docs.slack.dev/authentication/using-token-rotation">Token rotation</a> changes that, and it comes with a warning worth repeating: <strong>rotation can't be turned off once it's turned on.</strong> Enable it on a test app first.</p>
<p>With rotation on, tokens live twelve hours and arrive with a refresh token. The refresh call reuses the same endpoint as the initial exchange, with a different grant type:</p>
<pre><code class="language-javascript">async refresh(refreshToken) {
  const response = await fetch(this.tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.SLACK_CLIENT_ID,
      client_secret: process.env.SLACK_CLIENT_SECRET,
    }),
  });

  const json = await response.json();
  if (!json.ok) {
    throw new Error(`Slack token refresh failed: ${json.error}`);
  }

  return normalizeSlackTokens(json.authed_user ?? json);
}
</code></pre>
<p><strong>Store the new refresh token, not just the new access token.</strong> Refresh tokens rotate too. Writing back only the access token leaves you holding a spent refresh token, and the failure arrives twelve hours later, which is a long time to wait to learn something.</p>
<p>That write-back is why <code>getAccessToken</code> calls <code>saveGrant</code> after refreshing rather than returning the token and moving on.</p>
<h3 id="heading-treat-a-dead-grant-as-a-normal-state">Treat a Dead Grant as a Normal State</h3>
<p>A revoked grant isn't an exception in the exceptional sense. Users leave, administrators tighten scopes, and people change their minds about what an agent may do.</p>
<p>The shape that works is the one <code>getAccessToken</code> already uses: catch the failure, and surface a fresh authorization link rather than a stack trace. <code>connect.js</code> with the same identifier lets the user re-consent, <code>ON CONFLICT</code> overwrites the dead row, and nothing else in your user record changes.</p>
<h2 id="heading-how-to-add-a-second-provider">How to Add a Second Provider</h2>
<p><strong>A second provider costs one OAuth app, one entry in the providers object, and one tool.</strong> Keeping identity in a single string is what buys the discount.</p>
<p>The agent has used two providers all along. Worth noticing is what the second one didn't require: no second identity, no second consent server, and no second token table.</p>
<pre><code class="language-javascript">export const providers = {
  slack: { /* ... */ },
  github: { /* ... */ },
};
</code></pre>
<p>Google Calendar as a third means a third entry with its own <code>authorizeUrl</code>, <code>tokenUrl</code>, scopes, and <code>exchangeCode</code>. The consent server loops over <code>Object.keys(providers)</code>, so it picks the new one up without modification. The store already keys on <code>(identifier, provider)</code>, so it needs no migration. Then one more tool:</p>
<pre><code class="language-javascript">const createCalendarEvent = tool({
  description: 'Create a calendar event',
  inputSchema: z.object({ summary: z.string(), start: z.string() }),
  execute: async ({ summary, start }) =&gt; {
    const token = await getAccessToken(identifier, 'google-calendar');
    // ...one more provider call
  },
});
</code></pre>
<p>The identifier doesn't change, your user table doesn't change, and the model's view of the world grows by exactly one tool.</p>
<p>The cost that doesn't scale down is the <strong>provider-specific knowledge.</strong> Each new provider brings its own scope vocabulary, its own error format, and its own answer to whether tokens expire. Slack and GitHub disagreed on all three, and a third will disagree differently. The registry pattern contains that knowledge in one object per provider rather than spreading it through your agent, but it doesn't make the knowledge unnecessary.</p>
<p><strong>One caveat on consent:</strong> A grant is per user, per provider. Alice connecting Slack but not Calendar means her calendar tool calls fail, and failure is correct there, since she never consented. Treat it as a prompt to connect rather than an error, a point the Failure Modes section returns to.</p>
<h2 id="heading-full-walkthrough">Full Walkthrough</h2>
<p>Clone the repository, install, and fill in <code>.env</code>:</p>
<pre><code class="language-bash">git clone https://github.com/saif-shines/channel-watcher-agent.git
cd channel-watcher-agent
npm install
cp .env.example .env
# fill in both client IDs and secrets, the encryption key, channel ID, repo
</code></pre>
<p>Then connect. The command starts the callback server and prints one link per unconnected provider:</p>
<pre><code class="language-bash">npm run connect
</code></pre>
<pre><code class="language-text">[slack] authorize as "channel-watcher-agent":
https://slack.com/oauth/v2/authorize?client_id=123.456&amp;user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&amp;redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&amp;state=1159699dbf1a808fd33ba31c7b643505
[slack] connected.

[github] authorize as "channel-watcher-agent":
https://github.com/login/oauth/authorize?client_id=Iv1.abc&amp;scope=repo&amp;redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&amp;state=e6d13461099c391367266235f8313630
[github] connected.

All providers connected. Run: node src/index.js channel-watcher-agent
</code></pre>
<p>Open each link, consent, and the tab confirms. The state parameter in those URLs is checked on the way back. A callback carrying anything else gets a 400 and never reaches the token exchange.</p>
<p>Then run the agent against a channel holding ordinary chatter:</p>
<pre><code class="language-bash">node src/index.js
</code></pre>
<p>The output from that run, against a channel with three unremarkable messages:</p>
<pre><code class="language-text">[channel-watcher-agent] 3 messages fetched, 3 new.

--- Alex: "Sending draft message" ---
The message "Sending draft message" is noise — it appears to be a test or
accidental send, not a bug report or concrete action item.

...
</code></pre>
<p><strong>No tools called, and no issues filed.</strong> The negative case matters more than it looks. An agent with write access that can't say no is a liability, and a run over ordinary chatter is the cheapest available test of its restraint.</p>
<p>Now post an actual bug report in the channel:</p>
<blockquote>
<p>hey the /export endpoint is timing out for any file over 50MB, been happening since yesterday's deploy</p>
</blockquote>
<p>Run the agent again, and the state file keeps the earlier messages from being triaged twice.</p>
<p><strong>Authorship is the part that matters.</strong> The GitHub account behind the identifier opens the issue, using that user's own OAuth grant, not a shared bot. The Slack reply comes from that person too. Revoke their access and the next run fails at <code>getAccessToken</code>, which is the correct outcome.</p>
<h3 id="heading-what-changes-for-a-second-user">What Changes for a Second User</h3>
<p>The identifier comes from the command line, so isolation is testable without building a login first:</p>
<pre><code class="language-bash">node src/index.js                     # the identifier you already authorized
node src/index.js alice@example.com   # a different user entirely
</code></pre>
<p>The second command never reads the channel. It stops:</p>
<pre><code class="language-text">[slack] no grant for "alice@example.com".
Connect it first: node src/connect.js alice@example.com
</code></pre>
<p><strong>The refusal is the whole point.</strong> Nothing about the agent changed between the two commands. Same providers, tools, and code. Only the identifier differed, and Alice hasn't consented, so no row exists to decrypt and the run stops before touching Slack.</p>
<p>A shared-bot version behaves differently. The second command would read the channel and file an issue as the bot, because no per-user grant was ever involved.</p>
<p>Once Alice consents, everything downstream follows her grant. <code>readGrant</code> returns her row. <code>getAccessToken</code> decrypts her token. The Slack read returns the channels she can see, and her GitHub account authors the issue.</p>
<p>Production replaces <code>argv</code> with a session lookup:</p>
<pre><code class="language-javascript">const identifier = await lookupIdentifier(session.userId);
</code></pre>
<h3 id="heading-testing-the-isolation-without-credentials">Testing the Isolation Without Credentials</h3>
<p>The repository includes a test suite that replaces <code>fetch</code> with stand-in Slack and GitHub endpoints, so the request building, response parsing, storage, and refresh logic all run without a single OAuth app registered:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>Three of those tests are worth naming, because they check the claims this tutorial makes rather than the code's internals:</p>
<ul>
<li><p><strong>Slack exchange keeps the user token and discards the bot token.</strong> The fixture returns both. The test asserts the stored value is the <code>xoxp-</code> one.</p>
</li>
<li><p><strong>Two users get two different tokens from identical tool inputs.</strong> Same <code>text</code>, same <code>thread_ts</code>, two identifiers, two different <code>Authorization</code> headers reaching the provider.</p>
</li>
<li><p><strong>A tool built for an unconnected user fails instead of falling back.</strong> It also asserts that zero provider calls were attempted, since failing after leaking a request isn't much of a failure.</p>
</li>
</ul>
<p>Tests that pass on the first run are worth distrusting, so I checked these by breaking the code on purpose. Substituting the bot token for the user token, ignoring the identifier in <code>buildTools</code>, and exposing <code>identifier</code> in the model-visible schema each fail at least one test.</p>
<h2 id="heading-how-to-apply-the-pattern-to-other-use-cases">How to Apply the Pattern to Other Use Cases</h2>
<p><strong>Nothing in the pattern is specific to Slack triage.</strong> The shape is: read from one app, decide with a model, write to another app, all as one user.</p>
<p>Swapping the providers produces a different product:</p>
<table>
<thead>
<tr>
<th>Read from</th>
<th>Write to</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Slack</td>
<td>GitHub</td>
<td>Triage channel chatter into issues, as in this tutorial</td>
</tr>
<tr>
<td>Gmail</td>
<td>Linear</td>
<td>Turn support email into tracked work</td>
</tr>
<tr>
<td>Google Calendar</td>
<td>Notion</td>
<td>Meeting prep notes, drafted before the meeting</td>
</tr>
<tr>
<td>Zendesk</td>
<td>Salesforce</td>
<td>Log support signals against the right account</td>
</tr>
<tr>
<td>GitHub</td>
<td>Slack</td>
<td>A digest of what changed, in the channel that cares</td>
</tr>
</tbody></table>
<p>Every row uses the same three pieces: a provider entry, a token lookup by identifier, and a tool. Only three things change: the OAuth app registrations, the API calls inside <code>execute</code>, and the input you write for the model.</p>
<p><strong>The input is where your product lives.</strong> OAuth is plumbing. Deciding which messages deserve an issue, and what the issue should say, is judgment, and judgment is the part worth your weeks.</p>
<p>The same code supports two deployment shapes:</p>
<ul>
<li><p><strong>Internal team agent:</strong> The identifier is the teammate who triggered the run. Runs on a schedule or a command.</p>
</li>
<li><p><strong>Customer-facing agent:</strong> The identifier comes from your tenant and user records. Runs on customer data, inside customer accounts.</p>
</li>
</ul>
<p>The code stays identical. The consequences of a wrong identifier do not.</p>
<h2 id="heading-what-went-wrong-when-i-built-this">What Went Wrong When I Built This</h2>
<p>These are problems I hit while building the project, in roughly the order they showed up. If you hit the same ones, the fix is usually small.</p>
<h3 id="heading-slack-wont-save-the-redirect-url">Slack Won't Save the Redirect URL</h3>
<p>The symptom arrives before any code runs: the Slack app configuration page refuses to accept <code>http://localhost:3000/callback</code>.</p>
<p>Slack requires HTTPS on redirect URLs with no exception for <code>localhost</code>. Issue a local certificate with <code>mkcert</code>, register the <code>https://</code> form, and point <code>TLS_CERT_PATH</code> and <code>TLS_KEY_PATH</code> at the files it wrote. GitHub accepts either scheme, so the same HTTPS URL works for both apps.</p>
<h3 id="heading-the-browser-warns-that-the-certificate-isnt-trusted">The Browser Warns That the Certificate Isn't Trusted</h3>
<p><code>mkcert -install</code> is the step that adds mkcert's local authority to your system trust store, and skipping it leaves a certificate no browser recognises.</p>
<p>Running it once fixes every certificate mkcert issues afterwards. A self-signed certificate made with <code>openssl</code> will always warn, since nothing trusts it.</p>
<h3 id="heading-the-redirect-uri-doesnt-match">The Redirect URI Doesn't Match</h3>
<p>Both providers compare the <code>redirect_uri</code> you send against the one registered with the app, and the comparison is exact. A trailing slash, <code>127.0.0.1</code> in place of <code>localhost</code>, <code>http</code> where you registered <code>https</code>, or a different port all fail.</p>
<p>The error arrives before consent, on the provider's own page, which at least makes it easy to spot. Keep <code>OAUTH_REDIRECT_URI</code> as the single source and pass it in both the authorize URL and the token exchange, as the provider registry does.</p>
<h3 id="heading-the-callback-port-is-already-in-use">The Callback Port is Already in Use</h3>
<p><code>connect.js</code> binds the port from <code>OAUTH_REDIRECT_URI</code>, and port 3000 is popular. An unhandled <code>EADDRINUSE</code> produces a stack trace that says nothing about OAuth, so the project catches it and says what to do instead.</p>
<p>Changing the port means changing it in three places: <code>.env</code>, the Slack app's redirect URLs, and the GitHub app's callback URL. Missing one produces the previous failure.</p>
<h3 id="heading-the-state-check-rejects-a-legitimate-callback">The State Check Rejects a Legitimate Callback</h3>
<p>State values live in memory and are deleted once used. Restarting <code>connect.js</code> after opening the link, or refreshing the callback tab, both produce a state that's no longer in the map.</p>
<p>Both are correct rejections. Generate a fresh link and start again.</p>
<h3 id="heading-tool-calls-return-permission-errors-or-empty-results">Tool Calls Return Permission Errors or Empty Results</h3>
<p>A missing scope or a revoked grant causes both.</p>
<p>GitHub answers <code>403</code> with "Resource not accessible" when the grant lacks <code>repo</code>. Slack answers <code>200</code> with <code>ok: false</code> and an error like <code>missing_scope</code>. Fix the scope list, then send the user through consent again, since an existing grant doesn't gain scopes retroactively.</p>
<p><strong>A partially-scoped grant fails at the point of use rather than at connection time</strong>, which is what makes the symptom look mysterious. The consent screen succeeded, the token stored fine, and the failure arrives during a tool call hours later.</p>
<h3 id="heading-the-agent-reads-channels-it-shouldnt">The Agent Reads Channels it Shouldn't</h3>
<p>The single most likely cause is storing <code>json.access_token</code> instead of <code>json.authed_user.access_token</code> during the Slack exchange. Both are strings, both are truthy, and both work (one works as the app rather than the person).</p>
<p>The tell is the scope of what comes back. A user token returns only that person's channels. If <code>conversations.history</code> returns a channel the current user was never in, a bot token is in the store.</p>
<h3 id="heading-a-tool-call-runs-as-the-wrong-user">A Tool Call Runs as the Wrong User</h3>
<p>Passing a token or identifier belonging to somebody else will do the wrong thing correctly.</p>
<p>Two habits prevent it. Resolve the identifier server-side after authenticating the caller, never from client input. Then take the identifier as a closure argument in <code>buildTools</code> and let each <code>execute</code> fetch its own token, so no code path can pass a stray credential.</p>
<h3 id="heading-refresh-works-once-and-then-stops">Refresh Works Once and Then Stops</h3>
<p>Refresh tokens rotate. A refresh that writes back the new access token but keeps the old refresh token succeeds immediately and fails on the following cycle, which puts twelve hours between the bug and its symptom.</p>
<p><code>saveGrant</code> takes the whole normalized token object for this reason. Write back everything the refresh returned.</p>
<h3 id="heading-the-agent-files-duplicate-issues">The Agent Files Duplicate Issues</h3>
<p>Two causes. A missing or unwritten state file makes every run triage everything again. Or <code>stopWhen</code> allows enough rounds for a confused model to retry a tool that already succeeded.</p>
<p>Check the state file first. Then check whether the tool's return value clearly signals success, because an ambiguous result invites a retry.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built an agent that reads a Slack channel, judges which messages describe real work, files GitHub issues for those, and closes the loop with a threaded reply. Every call ran as one specific user's own OAuth grant, through an OAuth flow and a token store you wrote yourself.</p>
<p>Five ideas carry over to any provider:</p>
<ul>
<li><p><strong>A tool is an API call plus an explanation for a model</strong>, and the explanation is most of the work.</p>
</li>
<li><p><strong>The identifier replaces the token in your agent code.</strong> Everything above one small function handles a reference to a user rather than a credential, so tokens never reach your model inputs or your logs.</p>
</li>
<li><p><strong>Connection time and runtime are separate flows.</strong> Consent happens once per user, per app. Runtime resolves an identifier and fetches a token late.</p>
</li>
<li><p><strong>Authorization stays yours.</strong> A token store answers which tokens belong to an identifier. Whether a caller may act as that identifier is a question only your code can answer.</p>
</li>
<li><p><strong>Multi-provider support is a registry problem, not an architecture problem</strong>, once identity lives in one string.</p>
</li>
</ul>
<p>The detail that carries the most weight is also the smallest: <code>authed_user.access_token</code> rather than <code>access_token</code>. One property access decides whether your agent respects the permissions your workspace already has or quietly routes around them.</p>
<p>From here, keep the shape and swap the providers. Point the read half at Gmail and the write half at Linear, then rewrite the input for the model. The identity plumbing doesn't change.</p>
<p>The full source is at <a href="https://github.com/saif-shines/channel-watcher-agent">github.com/saif-shines/channel-watcher-agent</a>.</p>
<p><em>This write-up reconstructs what we learned building</em> <a href="https://www.scalekit.com/"><em>Scalekit</em></a><em>, a hosted version of the token vault you just built.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix the Dual-Write Problem in Node.js with the Outbox Pattern ]]>
                </title>
                <description>
                    <![CDATA[ Imagine you're building an e-commerce platform where placing an order needs to trigger several things at once: the warehouse has to be told to prepare the shipment, the email service has to send a con ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-the-dual-write-problem-in-node-js-with-the-outbox-pattern/</link>
                <guid isPermaLink="false">6a736f87fcec1e65edd2a703</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Gabor Koos ]]>
                </dc:creator>
                <pubDate>Wed, 05 Aug 2026 17:14:47 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/bcec9aaf-d418-4e5a-b8aa-f3c75b35f482.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine you're building an e-commerce platform where placing an order needs to trigger several things at once: the warehouse has to be told to prepare the shipment, the email service has to send a confirmation, and the fraud checker has to review the transaction.</p>
<p>The order service handles the checkout, saves the order to its database, and then publishes an <code>order.created</code> event to a message queue so every downstream system can react independently.</p>
<p>This is a common and reasonable design, but it has a reliability problem that's easy to miss until something goes wrong in production.</p>
<p>When a customer places an order and the payment goes through, the application needs to do two things: save the order to the database and publish the event to the queue. These are two separate writes to two separate systems, and there's no way to make them share a single atomic transaction. If the process crashes, the network hiccups, or a deployment rolls out between the two writes, one side commits and the other does not. The order sits confirmed on the customer's screen while the warehouse has no idea it exists.</p>
<p>The <a href="https://microservices.io/patterns/data/transactional-outbox.html">transactional outbox pattern</a> is the standard solution to this problem. In this article, we'll build it from scratch in Node.js, using PostgreSQL for the order service database, SQS for the queue, and DynamoDB as the fulfillment service's database. For local development, we'll use <a href="https://floci.io">floci</a>, a free open-source AWS emulator that runs all three with a single Docker container.</p>
<h2 id="heading-what-well-cover">What We'll Cover</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-problem-with-two-writes">The Problem with Two Writes</a></p>
</li>
<li><p><a href="#heading-the-outbox-pattern">The Outbox Pattern</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-database-schema">Database Schema</a></p>
</li>
<li><p><a href="#heading-the-request-handler">The Request Handler</a></p>
</li>
<li><p><a href="#heading-the-relay-worker">The Relay Worker</a></p>
</li>
<li><p><a href="#heading-the-consumer">The Consumer</a></p>
</li>
<li><p><a href="#heading-running-the-whole-thing">Running the Whole Thing</a></p>
</li>
<li><p><a href="#heading-going-to-production">Going to Production</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be comfortable with:</p>
<ul>
<li><p>Node.js and async/await</p>
</li>
<li><p>Database transactions (BEGIN, COMMIT, ROLLBACK)</p>
</li>
<li><p>The general concept of a message queue</p>
</li>
</ul>
<p>You don't need prior experience with AWS, SQS, or DynamoDB. We'll be running everything locally.</p>
<p>You will need Node.js 20 or later and Docker installed on your machine.</p>
<h2 id="heading-the-problem-with-two-writes">The Problem with Two Writes</h2>
<p>The order service scenario from the intro is one place this problem appears, but the same pattern comes up in many other contexts.</p>
<p>A user registers and the app inserts their account record, then sends a message to trigger the welcome email and the onboarding workflow. A file is uploaded and the API writes the metadata to the database, then publishes a message to kick off a processing worker for virus scanning or thumbnail generation. A payment webhook arrives, the handler records it in the database, then notifies downstream services that the payment is confirmed.</p>
<p>In every case, the application needs two writes to succeed together: one to the database and one to a queue or external system. If the second one is lost, the first one has no way of knowing.</p>
<p>If you want a deeper look at what database transactions actually guarantee and where they stop helping, see <a href="https://blog.gaborkoos.com/posts/2026-08-01-Beyond-Happy-Path-Engineering-Databases/">Beyond Happy Path Engineering: Databases</a>.</p>
<p>The naïve implementation looks straightforward:</p>
<pre><code class="language-js">await db.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
</code></pre>
<p>The database write happens first, then the queue write. Under normal conditions this works fine. The problem is what happens when something goes wrong between the two.</p>
<p>If the process crashes, runs out of memory, or gets killed mid-deployment after the database write but before <code>sqs.send</code> is called, the order record exists in the database but no event is ever published. The warehouse, email service, and fraud checker never find out the order happened. From the customer's perspective the order went through. From every downstream system's perspective it doesn't exist.</p>
<p>The failure can also go the other way. If <code>sqs.send</code> succeeds but the database write is later rolled back due to a constraint violation or an error in a subsequent step, you've published an event for an order that doesn't actually exist. A consumer acting on that event may try to fulfill an order with no corresponding record, or charge a customer for something that was never saved.</p>
<p>There's also a timing window even when both writes eventually succeed. Between the database commit and the successful <code>sqs.send</code>, a consumer that queries the database after receiving the event may not find the order yet, depending on transaction isolation and replication lag. These are two separate systems with no shared transaction boundary, and no amount of careful sequencing fully closes the gap.</p>
<p>These aren't edge cases that only happen under extraordinary circumstances. Deploys restart processes mid-request. Out-of-memory kills happen without warning. Networks drop connections at any point. Any of these can interrupt the two-write sequence, and the result is a system that's silently inconsistent with no error logged and no alert fired.</p>
<p>A variation I've seen a few times that looks safer but is actually worse is wrapping both operations in a database transaction:</p>
<pre><code class="language-js">// PLEASE DO NOT EVER DO THIS
const client = await pool.connect();
await client.query('BEGIN');
await client.query('INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2)', [customerId, amountCents]);
await sqs.send(new SendMessageCommand({ QueueUrl: QUEUE_URL, MessageBody: JSON.stringify({ customerId, amountCents }) }));
await client.query('COMMIT');
</code></pre>
<p>The intent is to make the two writes feel like a unit, but a database transaction has no authority over SQS. The transaction can only roll back database operations. If <code>sqs.send</code> succeeds and then <code>COMMIT</code> fails, the message is already in the queue and can't be taken back. If the process crashes after <code>COMMIT</code> but before the function returns, the transaction committed and the message was sent, but the caller may retry, potentially inserting a duplicate order.</p>
<p>Beyond the correctness problems, this pattern holds an open database connection and any row locks for the entire duration of the SQS network call. SQS is normally fast, but under load, retries, or a degraded queue, that call can take seconds. Every other request trying to read or write the same rows has to wait. In a busy application, this is a reliable way to exhaust the connection pool and bring down unrelated parts of the service.</p>
<h2 id="heading-the-outbox-pattern">The Outbox Pattern</h2>
<p>The core idea is to stop treating the queue publish as a second write that happens after the database write, and instead make it part of the same database transaction.</p>
<p>Rather than calling <code>sqs.send</code> directly, the application inserts a row into an <code>outbox</code> table in the same transaction as the business record. A separate relay process reads the outbox table and publishes the messages to SQS. On the other end, a consumer receives the messages and writes to its own data store. In our case that is a fulfillment service writing to DynamoDB, completely separate from the order service's PostgreSQL database.</p>
<p>If the transaction rolls back for any reason, the outbox row disappears with it. There's no orphaned message in the queue because the message was never sent. If the application crashes after committing but before the relay runs, the outbox row is still there with <code>status='pending'</code>, and the relay will pick it up on its next iteration.</p>
<p>The only guarantee the pattern relies on is the one the database already provides: atomicity within a single transaction.</p>
<p>The relay worker is responsible for the eventual delivery guarantee. It runs on an interval, selects pending rows, publishes them to SQS, and marks them as sent only after SQS confirms receipt. If the relay crashes mid-run, it will reprocess the same rows on the next iteration, which means SQS may receive some messages more than once.</p>
<p>That's why the consumer needs to be <strong>idempotent</strong>: it must handle receiving the same message twice without creating duplicate fulfillment records. We'll cover how to implement that when we build the consumer.</p>
<p>This separation of concerns is what makes the pattern practical. The request handler commits one atomic database transaction and returns. The relay handles the network call to SQS asynchronously, at its own pace, with its own retry logic, without holding database connections open or blocking request handling. The consumer is fully decoupled from the order service and owns its own data store.</p>
<p>The diagram below illustrates the flow:</p>
<img src="https://cdn.hashnode.com/uploads/covers/68b08746916c71e1ed2db58e/ab0620f0-65c6-43f1-a406-00bfd4880cdc.svg" alt="Diagram: outbox pattern flow" style="display: block;" width="960" height="640" loading="lazy">

<h2 id="heading-what-well-build">What We'll Build</h2>
<p>Now let's see the whole thing in practice. We'll implement a simple order placement API. When a customer sends a request to place an order, the order service saves it to PostgreSQL and inserts a row into the outbox table, all in one atomic transaction. A relay worker wakes up periodically, reads the pending outbox rows, and publishes each one as a message to SQS. A separate fulfillment service receives those messages from the queue and creates fulfillment records in DynamoDB.</p>
<p>By the end, you'll have an HTTP endpoint you can call, and you'll be able to verify that placing an order triggers the creation of a fulfillment record in a completely separate database, owned by a completely separate service, without either service ever talking to the other directly.</p>
<p>You can find the complete working code at <a href="https://github.com/gkoos/article-outbox">github.com/gkoos/article-outbox</a>.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before you can run any code, you need to get floci running so you have local instances of PostgreSQL, SQS, and DynamoDB. You'll also need Node.js 20 or later and Docker installed.</p>
<p>Start by cloning the repository and installing dependencies:</p>
<pre><code class="language-bash">git clone https://github.com/gkoos/article-outbox
cd article-outbox
npm install
</code></pre>
<p>Next, start floci. This command pulls the latest floci image and starts a Docker container that exposes a local AWS API endpoint (make sure Docker is running):</p>
<pre><code class="language-bash">npm run floci:start
</code></pre>
<p>On Linux and macOS, this just works. On Windows with Docker Desktop, <strong>you need to edit the</strong> <code>floci:start</code> <strong>script in your</strong> <code>package.json</code> <strong>to change the Docker socket mount from</strong> <code>/var/run/docker.sock</code> <strong>to</strong> <code>//var/run/docker.sock</code>.</p>
<p>The floci container is now listening on port 4566 and can spin up RDS (PostgreSQL), SQS, and DynamoDB instances on demand.</p>
<p>Now provision the AWS resources with a single setup command:</p>
<pre><code class="language-bash">npm run setup
</code></pre>
<p>This script creates an RDS PostgreSQL database instance, an SQS queue named <code>orders</code>, and a DynamoDB table named <code>fulfillments</code>. It waits for RDS to become available and then writes a <code>.env</code> file with the correct connection details. The environment variables <code>PG_PORT</code>, <code>SQS_QUEUE_URL</code>, and <code>DYNAMODB_TABLE_NAME</code> now point to the local emulated services.</p>
<p>Finally, create the PostgreSQL tables:</p>
<pre><code class="language-bash">npm run migrate
</code></pre>
<p>This creates the <code>orders</code> table and the <code>outbox</code> table in PostgreSQL. You now have a fully functional local environment ready to build against.</p>
<h2 id="heading-database-schema">Database Schema</h2>
<p>The two tables are simple. <code>orders</code> holds the business records: each order has a customer ID, an amount in cents, and a timestamp. The <code>outbox</code> table is the heart of the pattern: it's where the application writes the event that needs to be published.</p>
<pre><code class="language-sql">CREATE TABLE orders (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  customer_id TEXT NOT NULL,
  amount_cents INTEGER NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE outbox (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  created_at TIMESTAMPTZ DEFAULT now(),
  sent_at TIMESTAMPTZ
);

CREATE INDEX ON outbox (status, created_at) WHERE status = 'pending';
</code></pre>
<p>The <code>orders</code> table needs nothing special. The <code>outbox</code> table stores the event metadata: what type of event it is (<code>event_type</code>), what data it contains (<code>payload</code> as JSON), and whether it has been sent yet (<code>status</code>).</p>
<p>The status starts as <code>pending</code>. When the relay publishes it to SQS, it will mark it as <code>sent</code> and record the timestamp. The index on <code>(status, created_at) WHERE status = 'pending'</code> lets the relay quickly find the next batch of unsent events without scanning the entire table.</p>
<h2 id="heading-the-request-handler">The Request Handler</h2>
<p>This is where the pattern starts. The request handler receives an HTTP POST, inserts an order into the database, inserts a corresponding row into the outbox table, and commits everything in a single atomic transaction. The key insight is that neither write succeeds unless both succeed.</p>
<pre><code class="language-js">const client = await pool.connect();
try {
  await client.query('BEGIN');

  // Insert the order record
  const { rows } = await client.query(
    'INSERT INTO orders (customer_id, amount_cents) VALUES ($1, $2) RETURNING *',
    [customerId, amountCents]
  );
  const order = rows[0];

  // Insert the outbox record in the same transaction
  await client.query(
    `INSERT INTO outbox (event_type, payload)
     VALUES ($1, $2)`,
    ['order.created', JSON.stringify({ orderId: order.id, customerId: order.customer_id, amountCents: order.amount_cents, createdAt: order.created_at })],
  );

  await client.query('COMMIT');
  res.status(201).json(order);
} catch (err) {
  await client.query('ROLLBACK');
  next(err);
} finally {
  client.release();
}
</code></pre>
<p>The handler gets <code>customerId</code> and <code>amountCents</code> from the request body, starts an explicit transaction with <code>BEGIN</code>, and inserts the order. Then it inserts an outbox row with the order data as the payload.</p>
<p>Everything commits atomically. If anything fails, everything rolls back and the client gets an error. If the process crashes between the commit and the response, the client won't get a 201, but the order and the outbox row are still safely committed to the database and the relay will eventually pick it up. The handler doesn't call SQS at all. That is the relay's job.</p>
<h2 id="heading-the-relay-worker">The Relay Worker</h2>
<p>The relay worker is a separate process that polls the outbox table every second and publishes pending rows to SQS. It runs independently of the HTTP server and has no shared state with it.</p>
<pre><code class="language-js">async function relay() {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const { rows } = await client.query(`
      SELECT *
      FROM outbox
      WHERE status = 'pending'
      ORDER BY created_at
      LIMIT 10
      FOR UPDATE SKIP LOCKED -- prevents multiple relays from processing the same rows
    `);

    for (const row of rows) {
      await sqsClient.send(new SendMessageCommand({
        QueueUrl: QUEUE_URL,
        MessageBody: JSON.stringify(row.payload),
        MessageAttributes: {
          EventType: { DataType: 'String', StringValue: row.event_type },
        },
      }));

      await client.query(
        `UPDATE outbox SET status = 'sent', sent_at = now() WHERE id = $1`,
        [row.id],
      );
    }

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('Relay error:', err.message);
  } finally {
    client.release();
  }
}

setInterval(relay, 1000);
</code></pre>
<p><code>FOR UPDATE SKIP LOCKED</code> is the key to running multiple relay instances safely: when a relay picks up a batch of rows, it locks them. Any other relay instance trying to select the same rows will skip them and move to the next available ones, so you never get two relays publishing the same message from the same run.</p>
<p>The relay marks each row as <code>sent</code> only after <code>sqsClient.send</code> returns. If the relay crashes after sending to SQS but before updating the row, the row stays <code>pending</code> and the relay will resend it on the next iteration.</p>
<p>Note that the <code>UPDATE</code> happens inside the same transaction as the <code>SELECT FOR UPDATE</code>, so if the relay crashes mid-batch, the entire batch rolls back and all rows in it will be retried, including any that were already successfully sent to SQS.</p>
<p>The at-least-once delivery guarantee applies at the batch level, not the individual row level. You can read about this problem in <a href="https://blog.gaborkoos.com/posts/2026-07-01-Beyond-Happy-Path-Engineering-the-Network/">Beyond Happy Path Engineering: the Network</a>: when a response is lost, the caller can't know whether the operation succeeded, so it retries, and the receiver may see the same request twice. This means the consumer may see the same message more than once, which is why idempotency matters on the consumer side.</p>
<h2 id="heading-the-consumer">The Consumer</h2>
<p>The consumer is a completely separate service. It knows nothing about the order service's PostgreSQL database. Its only input is the SQS queue, and its only output is the DynamoDB <code>fulfillments</code> table. This is the point of the pattern: the two services are decoupled by the queue, and each owns its own data store.</p>
<p>As we saw earlier, because SQS delivers at least once (meaning a message might be delivered more than once), the consumer must be idempotent. The <code>PutItem</code> call uses a <code>ConditionExpression</code> that makes the write a no-op if a fulfillment record for that order already exists, so redelivered messages are handled safely.</p>
<pre><code class="language-js">async function consume() {
  const { Messages } = await sqsClient.send(new ReceiveMessageCommand({
    QueueUrl:              QUEUE_URL,
    WaitTimeSeconds:       20,   // long-poll: wait up to 20s for messages
    MaxNumberOfMessages:   10,
    MessageAttributeNames: ['All'],
  }));

  for (const msg of Messages ?? []) {
    const event = JSON.parse(msg.Body);

    try {
      await dynamoClient.send(new PutItemCommand({
        TableName: 'fulfillments',
        Item: {
          orderId:     { S: event.orderId },
          customerId:  { S: event.customerId },
          amountCents: { N: String(event.amountCents) },
          status:      { S: 'received' },
          createdAt:   { S: new Date().toISOString() },
        },
        ConditionExpression: 'attribute_not_exists(orderId)', // idempotency check
      }));
    } catch (err) {
      if (err.name !== 'ConditionalCheckFailedException') throw err;
      // already processed, safe to continue
    }

    // delete the message only after the write succeeds (or was already done)
    await sqsClient.send(new DeleteMessageCommand({
      QueueUrl:      QUEUE_URL,
      ReceiptHandle: msg.ReceiptHandle,
    }));
  }
}
</code></pre>
<p><code>ConditionExpression: 'attribute_not_exists(orderId)'</code> tells DynamoDB to reject the write if a record with that <code>orderId</code> already exists. When that happens, DynamoDB throws a <code>ConditionalCheckFailedException</code>. The consumer catches that specific error and ignores it, then deletes the message from the queue and moves on. Any other error is rethrown and the message stays in the queue to be retried.</p>
<p>The <code>DeleteMessage</code> call happens after the DynamoDB write, not before. If the process crashes between the write and the delete, SQS will redeliver the message and the condition check will handle it. If the process crashes before the write, the message stays in the queue and will be processed normally on the next delivery.</p>
<h2 id="heading-running-the-whole-thing">Running the Whole Thing</h2>
<p>With floci running and the resources provisioned, open three terminal tabs and start each process:</p>
<pre><code class="language-bash">node src/server.js    # the order API on port 3000
node src/relay.js     # the outbox relay
node src/consumer.js  # the fulfillment consumer
</code></pre>
<p>Now place an order:</p>
<pre><code class="language-bash">curl -X POST localhost:3000/orders \
  -H 'Content-Type: application/json' \
  -d '{"customerId":"c1","amountCents":4999}'
</code></pre>
<p>You should get back a 201 with the new order record:</p>
<pre><code class="language-bash">{"id":"1768d35b-083d-45f1-adb5-4063d8d7fcab","customer_id":"c1","amount_cents":4999,"created_at":"2026-07-30T20:27:10.628Z"}
</code></pre>
<p>Within a second the relay will pick up the outbox row and publish it to SQS. The consumer will receive the message and write a fulfillment record to DynamoDB. The repo includes a convenience script to verify this:</p>
<pre><code class="language-bash">npm run check
</code></pre>
<p>You should see a fulfillment record with the <code>orderId</code> from the order you just placed:</p>
<pre><code class="language-bash">{
  orderId: 'c335640e-bc4a-47e4-afed-484c95fbd6d3',
  customerId: 'c1',
  amountCents: '4999',
  status: 'received',
  createdAt: '2026-07-30T19:02:54.929Z'
}
</code></pre>
<h2 id="heading-going-to-production">Going to Production</h2>
<p>Because the local setup uses floci to emulate AWS, switching to real AWS requires no code changes at all. The AWS SDK reads the endpoint from <code>AWS_ENDPOINT_URL</code> in the environment. In production, you simply don't set that variable and the SDK talks to real AWS using the credentials and region from the standard environment variables (<code>AWS_REGION</code>, <code>AWS_ACCESS_KEY_ID</code>, <code>AWS_SECRET_ACCESS_KEY</code>, or an IAM role if you are running on EC2 or ECS).</p>
<p>Running multiple relay instances is safe out of the box because of <code>FOR UPDATE SKIP LOCKED</code>. You can scale the relay horizontally and each instance will pick up a different set of rows without duplicating messages.</p>
<p>One thing worth adding before going to production is handling permanent failures in the relay. Right now the relay only uses <code>pending</code> and <code>sent</code>. You should add a <code>failed</code> status and a retry counter: after a row has failed N times, mark it <code>failed</code> and stop retrying it. Then configure a dead-letter queue on the <code>orders</code> SQS queue as well, so that messages the consumer can't process after the maximum number of retries land somewhere you can inspect rather than disappearing silently.</p>
<p>For high-throughput systems where polling latency matters, <a href="https://en.wikipedia.org/wiki/Change_data_capture">change data capture</a> (CDC) is a common alternative to the polling relay. Tools like <a href="https://debezium.io/">Debezium</a> read directly from the PostgreSQL write-ahead log and publish changes to <a href="https://kafka.apache.org/">Kafka</a> or SQS without any polling delay. The outbox table and the consumer stay exactly the same, only the relay is replaced.</p>
<p>This is a bigger operational commitment than a polling worker, so polling is the right starting point for most systems.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The dual-write problem is easy to overlook because the naïve implementation works correctly most of the time. It only fails in the gaps between two separate system writes, and those gaps only become visible when something goes wrong at exactly the wrong moment. By the time you notice it in production, data is already inconsistent and there is no clean way to recover.</p>
<p>The transactional outbox pattern closes that gap at the database level. The outbox row is part of the same atomic commit as the business record, so the two are always in sync. The relay handles the network call to SQS independently, with its own retry logic, without touching the request lifecycle. The consumer handles at-least-once delivery with a single condition check on the write.</p>
<p>Each piece is simple on its own, and together they give you reliable, decoupled event delivery without distributed transactions.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Agent with Function Calling in Node.js Using Google Gemini ]]>
                </title>
                <description>
                    <![CDATA[ Last year, a client asked me to add a conversational interface to their internal reporting tool. Staff would type a question, and the system would pull a live answer from the database. I had the first ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-ai-agent-function-calling-nodejs-gemini/</link>
                <guid isPermaLink="false">6a63af5c9a1ab0289b0cbb6a</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gemini ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Fri, 24 Jul 2026 18:30:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ef2a5058-558c-4951-abff-4d51f1c5cd15.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Last year, a client asked me to add a conversational interface to their internal reporting tool. Staff would type a question, and the system would pull a live answer from the database.</p>
<p>I had the first version running in a day. Single questions worked. But a week in, a tester typed: "What is the weather in Berlin, and how much would 500 EUR convert to in USD right now?"</p>
<p>The model called the weather function, returned that answer, and ignored the second half of the question entirely.</p>
<p>That is the gap between a chatbot and an agent. A chatbot works from training data. That's its limit. An agent doesn't have that limit. It calls a tool, reads what came back, and decides whether to keep going.</p>
<p>Most questions resolve in one or two tool calls. Multi-step ones take a few more. Remove that loop, and they all break.</p>
<p>This tutorial shows you how to build that loop with Google Gemini's function calling API and Node.js. You'll build an agent that can call real external tools: Open-Meteo for live weather, frankfurter.app for live currency rates, and a math evaluator for calculations. All three are completely free. The only API key you need is Gemini, which is also free on Google AI Studio at 1,500 requests per day.</p>
<p>Everything is on GitHub: <a href="https://github.com/ziaongit/nodejs-gemini-agent">github.com/ziaongit/nodejs-gemini-agent</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-function-calling-works">How Function Calling Works</a></p>
</li>
<li><p><a href="#heading-what-were-building">What We're Building</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-defining-the-tools">Defining the Tools</a></p>
</li>
<li><p><a href="#heading-implementing-the-tool-functions">Implementing the Tool Functions</a></p>
</li>
<li><p><a href="#heading-building-the-agentic-loop">Building the Agentic Loop</a></p>
</li>
<li><p><a href="#heading-the-cli-entry-point">The CLI Entry Point</a></p>
</li>
<li><p><a href="#heading-adding-an-express-http-server">Adding an Express HTTP Server</a></p>
</li>
<li><p><a href="#heading-testing-the-agent">Testing the Agent</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-what-to-build-next">What to Build Next</a></p>
</li>
</ul>
<h2 id="heading-how-function-calling-works">How Function Calling Works</h2>
<p>Most LLM tutorials show function calling as: define a function, the model calls it, done. That framing skips the part that actually matters.</p>
<p>The model doesn't call your function. It can't. What happens is more like a negotiation.</p>
<p>You send the model a message along with a list of tool descriptions. Each description is a JSON schema: the function name, what it does, and what arguments it needs. Gemini reads those at request time to decide which tool, if any, fits what the user asked.</p>
<p>Here's the part that surprises people. Gemini doesn't run your code. It sends back a structured object that says: call <code>get_weather</code>, <code>city = Berlin</code>. Your code picks that up, runs the actual function, and sends the result back. Gemini checks whether that's enough to answer. If not, it requests another tool.</p>
<p>That exchange is the loop:</p>
<pre><code class="language-plaintext">User message
      │
      ▼
Model + tool schemas
      │
      ▼
Response: functionCall?
      │
   YES │                          NO
      ▼                            ▼
Run the function(s)         Return text answer
      │
      ▼
Send result(s) back to model
      │
      └──── loop back ────────────┘
</code></pre>
<p>The loop keeps running until the model decides it has enough to answer. That's what allows it to chain calls: check the weather, see the temperature is above 25°C, then decide it should also fetch the exchange rate before answering.</p>
<p>There's one detail that trips people up the first time. When Gemini requests multiple tools in the same response, you run all of them and return all results in a single message. Returning them one at a time in separate messages breaks the model's turn-tracking and produces unreliable output.</p>
<h2 id="heading-what-were-building">What We're Building</h2>
<p>In this tutorial, we'll build an AI agent with three working tools:</p>
<ul>
<li><p><code>get_weather</code> — fetches current weather for any city via Open-Meteo (free, no API key)</p>
</li>
<li><p><code>calculate</code> — evaluates a math expression safely in JavaScript</p>
</li>
<li><p><code>get_exchange_rate</code> — fetches live currency rates via frankfurter.app (free, no API key)</p>
</li>
</ul>
<p>There are two ways to run it: a readline CLI for quick local testing, and an Express HTTP endpoint to wire into a real application.</p>
<p>Full tech stack:</p>
<ul>
<li><p><strong>Node.js 20</strong>: runtime (Node 18 minimum for native fetch)</p>
</li>
<li><p><strong>@google/generative-ai</strong>: official Gemini SDK</p>
</li>
<li><p><strong>dotenv</strong>: environment variable loading</p>
</li>
<li><p><strong>Express</strong>: HTTP server for the API endpoint</p>
</li>
<li><p><strong>Open-Meteo API</strong>: free weather and geocoding, no key required</p>
</li>
<li><p><strong>frankfurter.app</strong>: free currency exchange rates, no key required</p>
</li>
</ul>
<p>Architecture:</p>
<pre><code class="language-plaintext">┌─────────────────────────────────────────────────┐
│                   Client                         │
│         CLI (readline) / HTTP POST               │
└──────────────────────┬──────────────────────────┘
                       │  user message
                       ▼
┌─────────────────────────────────────────────────┐
│               agent.js — Agentic Loop            │
│                                                  │
│  1. Send message + tool schemas to Gemini        │
│  2. Receive response                             │
│  3. functionCalls() present?                     │
│      YES → execute tools in parallel             │
│            send all results back                 │
│            go to step 2                          │
│      NO  → return final text answer              │
└──────────────────────┬──────────────────────────┘
                       │  tool calls
                       ▼
┌─────────────────────────────────────────────────┐
│                  Tool Handlers                   │
│                                                  │
│  get_weather(city)                               │
│    └─► geocoding-api.open-meteo.com              │
│        api.open-meteo.com                        │
│                                                  │
│  calculate(expression)                           │
│    └─► JS safe evaluator (no external call)      │
│                                                  │
│  get_exchange_rate(from, to, amount?)            │
│    └─► api.frankfurter.app                       │
└─────────────────────────────────────────────────┘
</code></pre>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, you should have:</p>
<ul>
<li><p>Node.js 18 or higher — run <code>node --version</code> to check</p>
</li>
<li><p>A Gemini API key from <a href="https://aistudio.google.com">aistudio.google.com</a> — free, no card. The free tier gives you 1,500 requests a day.</p>
</li>
<li><p>You should also know how <code>async/await</code> works in Node.js. That's about it.</p>
</li>
</ul>
<h2 id="heading-project-setup">Project Setup</h2>
<pre><code class="language-bash">mkdir nodejs-gemini-agent &amp;&amp; cd nodejs-gemini-agent
npm init -y
npm install @google/generative-ai dotenv express
mkdir src
</code></pre>
<p>Add a <code>.gitignore</code>, as you don't want <code>.env</code> in your repo:</p>
<pre><code class="language-plaintext">node_modules/
.env
</code></pre>
<p>Drop a <code>.env</code> at the root:</p>
<pre><code class="language-plaintext">GEMINI_API_KEY=your_api_key_here
PORT=3000

# Optional: override the default model (gemini-2.0-flash)
# Uncomment if you hit free-tier quota limits
# GEMINI_MODEL=gemini-2.0-flash-lite
</code></pre>
<p>Project structure:</p>
<pre><code class="language-plaintext">nodejs-gemini-agent/
├── src/
│   ├── tools.js        ← tool schemas for Gemini
│   ├── functions.js    ← actual implementations
│   ├── agent.js        ← the agentic loop
│   ├── index.js        ← CLI entry point
│   └── server.js       ← Express HTTP server
├── .env
├── .env.example
├── .gitignore
└── package.json
</code></pre>
<h2 id="heading-defining-the-tools">Defining the Tools</h2>
<p>Gemini can't see your code. It picks tools based entirely on the JSON schemas you pass in. Each schema has a name, a description, and the parameter definitions.</p>
<p>The description is what drives routing. Gemini reads it at request time to decide which tool fits the user's question. Focus on when to call the function, not just what it does.</p>
<pre><code class="language-js">// src/tools.js

const toolDefinitions = [
  {
    name: 'get_weather',
    description:
      'Get the current weather for a city. Returns temperature in Celsius, ' +
      'humidity percentage, and wind speed. Use this when the user asks about ' +
      'weather, temperature, or climate conditions in any location.',
    parameters: {
      type: 'OBJECT',
      properties: {
        city: {
          type: 'STRING',
          description: 'The city name, e.g. Tokyo, London, New York',
        },
      },
      required: ['city'],
    },
  },
  {
    name: 'calculate',
    description:
      'Evaluate a mathematical expression and return the numeric result. ' +
      'Use this for arithmetic, percentage calculations, or any numeric computation ' +
      'the user asks for. Do not guess at math — always call this tool.',
    parameters: {
      type: 'OBJECT',
      properties: {
        expression: {
          type: 'STRING',
          description:
            'A valid mathematical expression, e.g. "47.50 * 0.18" or "1500 / 12"',
        },
      },
      required: ['expression'],
    },
  },
  {
    name: 'get_exchange_rate',
    description:
      'Get the current exchange rate between two currencies. Can also convert ' +
      'a specific amount. Use this when the user asks about currency conversion, ' +
      'exchange rates, or how much a foreign currency amount is worth.',
    parameters: {
      type: 'OBJECT',
      properties: {
        from: {
          type: 'STRING',
          description: 'The source currency code, e.g. USD, EUR, JPY, GBP',
        },
        to: {
          type: 'STRING',
          description: 'The target currency code, e.g. USD, EUR, JPY, GBP',
        },
        amount: {
          type: 'NUMBER',
          description:
            'Amount to convert. Optional — defaults to 1 if not provided.',
        },
      },
      required: ['from', 'to'],
    },
  },
];

module.exports = { toolDefinitions };
</code></pre>
<p>Vague descriptions work most of the time. The problems show up at the edges. "Does currency stuff" routes fine on a simple question. It falls apart on anything ambiguous. "Get the current exchange rate between two currencies. Use this when the user asks about currency conversion." holds up. The extra words cost basically nothing. Debugging bad routing costs a lot more.</p>
<h2 id="heading-implementing-the-tool-functions">Implementing the Tool Functions</h2>
<p>These are the actual functions that run when the model requests them. Each receives the arguments the model decided to pass, does real work, and returns a plain JavaScript object.</p>
<pre><code class="language-js">// src/functions.js

async function get_weather({ city }) {
  // Open-Meteo uses a two-step approach: geocode the city first, then fetch weather.
  // Both APIs are free with no key required.
  const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&amp;count=1`;
  const geoRes  = await fetch(geoUrl);
  const geoData = await geoRes.json();

  if (!geoData.results?.length) {
    return { error: `City not found: ${city}` };
  }

  const { latitude, longitude, name, country } = geoData.results[0];

  const weatherUrl =
    `https://api.open-meteo.com/v1/forecast` +
    `?latitude=${latitude}&amp;longitude=${longitude}` +
    `&amp;current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code`;

  const weatherRes  = await fetch(weatherUrl);
  const weatherData = await weatherRes.json();
  const current     = weatherData.current;

  return {
    city:        `${name}, ${country}`,
    temperature: `${current.temperature_2m}°C`,
    humidity:    `${current.relative_humidity_2m}%`,
    wind_speed:  `${current.wind_speed_10m} km/h`,
  };
}

function calculate({ expression }) {
  try {
    // Strip anything that is not a number or basic operator before eval.
    // This is not a complete sandbox — use a proper math parser like mathjs
    // in production if expressions come from untrusted users.
    const safe = expression.replace(/[^0-9+\-*/.() %]/g, '');
    if (!safe.trim()) return { error: 'Invalid or empty expression' };

    const result = Function('"use strict"; return (' + safe + ')')();
    return { expression, result };
  } catch {
    return { error: `Could not evaluate: ${expression}` };
  }
}

async function get_exchange_rate({ from, to, amount = 1 }) {
  const url  = `https://api.frankfurter.app/latest?from=${from.toUpperCase()}&amp;to=${to.toUpperCase()}`;
  const res  = await fetch(url);
  const data = await res.json();

  if (data.error) return { error: data.error };

  const rate      = data.rates[to.toUpperCase()];
  if (!rate) return { error: `No rate found for ${from} → ${to}` };

  const converted = parseFloat((amount * rate).toFixed(4));

  return { from: from.toUpperCase(), to: to.toUpperCase(), rate, amount, converted };
}

module.exports = { get_weather, calculate, get_exchange_rate };
</code></pre>
<p>There are a few things worth pointing out here.</p>
<p>Open-Meteo uses geocoding before the weather fetch. Passing latitude and longitude directly to the weather endpoint is more reliable than a city name string, and the geocoding API handles misspellings reasonably well. There are two fetch calls, but that's the trade-off for accuracy.</p>
<p>The <code>calculate</code> function strips everything except digits and operators before evaluation. It narrows the injection risk, but it's not a real sandbox. If you expose this over HTTP with anonymous users, use a proper math parser like <a href="https://mathjs.org">mathjs</a>.</p>
<p>Frankfurter converts currency codes to uppercase before the request. Users will type "usd" or "Usd" and both should work. The API is case-sensitive on its end.</p>
<h2 id="heading-building-the-agentic-loop">Building the Agentic Loop</h2>
<p>This is the file everything else supports. The loop itself is about 20 lines. The rest is logging and error handling.</p>
<pre><code class="language-js">// src/agent.js
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { toolDefinitions }    = require('./tools');
const { get_weather, calculate, get_exchange_rate } = require('./functions');

const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);

// Map tool names to handler functions
const toolHandlers = { get_weather, calculate, get_exchange_rate };

async function runAgent(userMessage) {
  const model = genAI.getGenerativeModel({
    model: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
    tools: [{ functionDeclarations: toolDefinitions }],
  });

  const chat = model.startChat();

  console.log(`\nUser: ${userMessage}`);
  console.log('---');

  let response = await chat.sendMessage(userMessage);
  let iterations = 0;
  const MAX_ITERATIONS = 10; // safety cap against infinite loops

  // Agentic loop
  while (iterations &lt; MAX_ITERATIONS) {
    iterations++;
    const calls = response.response.functionCalls();

    // No tool calls — model is done, return the answer
    if (!calls || calls.length === 0) break;

    // Run all requested tools, collect results
    const toolResults = await Promise.allSettled(
      calls.map(async (call) =&gt; {
        console.log(`Calling tool: ${call.name}(${JSON.stringify(call.args)})`);

        const handler = toolHandlers[call.name];

        if (!handler) {
          return {
            functionResponse: {
              name:     call.name,
              response: { error: `Unknown tool: ${call.name}` },
            },
          };
        }

        try {
          const result = await handler(call.args);
          console.log(`Tool result: ${JSON.stringify(result)}`);
          return {
            functionResponse: {
              name:     call.name,
              response: result,
            },
          };
        } catch (err) {
          return {
            functionResponse: {
              name:     call.name,
              response: { error: err.message },
            },
          };
        }
      })
    );

    // Extract values from allSettled (fulfilled only — errors already caught above)
    const parts = toolResults
      .filter(r =&gt; r.status === 'fulfilled')
      .map(r =&gt; r.value);

    // Send all results back to the model in one message
    response = await chat.sendMessage(parts);
  }

  return response.response.text();
}

module.exports = { runAgent };
</code></pre>
<p>The <code>MAX_ITERATIONS</code> cap isn't paranoia. A model can get into a loop if a tool keeps returning an error and the model keeps retrying. 10 iterations is more than enough for any real query. A complex multi-tool question typically resolves in two or three turns.</p>
<p><code>Promise.allSettled</code> runs all requested tools in parallel rather than sequentially. When the model requests both weather and exchange rate in the same response, they fetch simultaneously. Individual tool failures get caught inside the map rather than letting one failure abort the others.</p>
<p>The logging is intentional. When you're building and testing an agent, watching the tool calls happen in real time tells you whether the routing is working. Production code would route these to a structured logger instead of console output, but the information is the same.</p>
<h2 id="heading-the-cli-entry-point">The CLI Entry Point</h2>
<p><code>require('dotenv').config()</code> runs first so your API key loads before anything else. After that it's a standard readline loop: each line you type goes to <code>runAgent</code>, the response prints, and the loop waits for the next input.</p>
<pre><code class="language-js">// src/index.js
require('dotenv').config();
const readline     = require('readline');
const { runAgent } = require('./agent');

const rl = readline.createInterface({
  input:  process.stdin,
  output: process.stdout,
});

function ask(prompt) {
  return new Promise(resolve =&gt; rl.question(prompt, resolve));
}

async function main() {
  console.log('Gemini Agent — type your question, or "exit" to quit\n');

  while (true) {
    const input = await ask('You: ');
    if (input.toLowerCase() === 'exit') break;
    if (!input.trim()) continue;

    try {
      const answer = await runAgent(input);
      console.log(`\nAgent: ${answer}\n`);
    } catch (err) {
      console.error(`Error: ${err.message}`);
    }
  }

  rl.close();
}

main();
</code></pre>
<h2 id="heading-adding-an-express-http-server">Adding an Express HTTP Server</h2>
<p>The CLI is useful for testing. For integrating into an app, you need an HTTP endpoint.</p>
<pre><code class="language-js">// src/server.js
require('dotenv').config();
const express      = require('express');
const { runAgent } = require('./agent');

const app  = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.post('/agent', async (req, res) =&gt; {
  const { message } = req.body;

  if (!message || typeof message !== 'string') {
    return res.status(400).json({ error: 'message is required and must be a string' });
  }

  try {
    const answer = await runAgent(message);
    res.json({ answer });
  } catch (err) {
    console.error('[agent error]', err.message);
    res.status(500).json({ error: 'Agent failed to process the request' });
  }
});

app.listen(PORT, () =&gt; {
  console.log(`Agent server running on http://localhost:${PORT}`);
});
</code></pre>
<p>Start it:</p>
<pre><code class="language-bash">node src/server.js
</code></pre>
<p>Call it:</p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/agent \
  -H "Content-Type: application/json" \
  -d '{"message": "What is the weather in Paris?"}'
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "answer": "The current weather in Paris, France is 19°C with 65% humidity and wind speeds of 12 km/h."
}
</code></pre>
<p>The POST body is a plain <code>{ message }</code> object. The response is a plain <code>{ answer }</code> string. The agent handles the rest.</p>
<h2 id="heading-testing-the-agent">Testing the Agent</h2>
<p>Start the CLI:</p>
<pre><code class="language-bash">node src/index.js
</code></pre>
<p>The <code>You:</code> prompt comes from the readline in <code>index.js</code>. The <code>User:</code> line and <code>---</code> separator are logged by <code>agent.js</code> at the start of each run. This is the same logging discussed in the agentic loop section.</p>
<p>Single tool — weather:</p>
<pre><code class="language-plaintext">You: What's the weather in Tokyo?

User: What's the weather in Tokyo?
---
Calling tool: get_weather({"city":"Tokyo"})
Tool result: {"city":"Tokyo, JP","temperature":"31°C","humidity":"72%","wind_speed":"8 km/h"}

Agent: The current weather in Tokyo, Japan is 31°C with 72% humidity and wind speeds of 8 km/h.
</code></pre>
<p>Two tools — chained reasoning:</p>
<pre><code class="language-plaintext">You: What is the weather in Tokyo? If it's above 20°C, convert 10000 JPY to EUR.

User: What is the weather in Tokyo? If it's above 20°C, convert 10000 JPY to EUR.
---
Calling tool: get_weather({"city":"Tokyo"})
Tool result: {"city":"Tokyo, JP","temperature":"31°C","humidity":"72%","wind_speed":"8 km/h"}

Calling tool: get_exchange_rate({"from":"JPY","to":"EUR","amount":10000})
Tool result: {"from":"JPY","to":"EUR","rate":0.006,"amount":10000,"converted":60.0}

Agent: The current temperature in Tokyo is 31°C, which is above 20°C.
Converting 10,000 JPY to EUR at the current exchange rate gives approximately 60.00 EUR.
</code></pre>
<p>Notice what happened: the model called <code>get_weather</code>, read the result (31°C), applied the conditional logic from the user's question on its own, and then called <code>get_exchange_rate</code>. You didn't write any of that branching logic. The model handled it from the description alone.</p>
<p>Calculator:</p>
<pre><code class="language-plaintext">You: How much is 18% tip on a $47.50 restaurant bill?

User: How much is 18% tip on a $47.50 restaurant bill?
---
Calling tool: calculate({"expression":"47.50 * 0.18"})
Tool result: {"expression":"47.50 * 0.18","result":8.55}

Agent: An 18% tip on a $47.50 bill is $8.55, making your total $56.05.
</code></pre>
<p>Three tools in one query:</p>
<pre><code class="language-plaintext">You: What's the weather in London and Berlin? And what is 250 GBP in EUR?

User: What's the weather in London and Berlin? And what is 250 GBP in EUR?
---
Calling tool: get_weather({"city":"London"})
Calling tool: get_weather({"city":"Berlin"})
Calling tool: get_exchange_rate({"from":"GBP","to":"EUR","amount":250})
Tool result: {"city":"London, GB","temperature":"16°C","humidity":"78%","wind_speed":"20 km/h"}
Tool result: {"city":"Berlin, DE","temperature":"22°C","humidity":"55%","wind_speed":"14 km/h"}
Tool result: {"from":"GBP","to":"EUR","rate":1.17,"amount":250,"converted":292.5}

Agent: London is currently 16°C with 78% humidity and 20 km/h winds.
Berlin is warmer at 22°C with 55% humidity and lighter winds of 14 km/h.
250 GBP converts to approximately 292.50 EUR at the current exchange rate.
</code></pre>
<p>All three tools ran in parallel. <code>Promise.allSettled</code> is why. A sequential loop would have made three serial network requests. Parallel gives you the same result in roughly the time of the slowest single request.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Here are a few common issues you might encounter, and how to fix them:</p>
<h3 id="heading-1-404-not-found-modelsgemini-15-flash-is-not-found-for-api-version-v1beta">1. <code>[404 Not Found] models/gemini-1.5-flash is not found for API version v1beta</code></h3>
<p>The model name is outdated. Google deprecates older aliases over time. Swap it out for <code>gemini-2.0-flash</code> in <code>agent.js</code>. To check what models your key can actually access, run:</p>
<pre><code class="language-bash">node -e "
const { GoogleGenerativeAI } = require('@google/generative-ai');
require('dotenv').config();
const g = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
g.listModels().then(r =&gt; r.models.forEach(m =&gt; console.log(m.name)));
"
</code></pre>
<h3 id="heading-2-429-too-many-requests-you-exceeded-your-current-quota">2. <code>[429 Too Many Requests] You exceeded your current quota</code></h3>
<p>The <code>gemini-2.0-flash</code> free tier caps you at 1,500 requests a day. Hit that and every call returns a 429 until midnight Pacific resets the counter.</p>
<p>The error names the quota ID directly. <code>GenerateRequestsPerDayPerProjectPerModel-FreeTier</code> means you hit the daily cap. <code>GenerateRequestsPerMinutePerProjectPerModel-FreeTier</code> means the per-minute rate.</p>
<p>For the per-minute limit, the error includes a <code>retryDelay</code> field. Wait that many seconds and retry. For the daily limit, the quota is per-project. All models under the same project share it.</p>
<p>There are three ways out:</p>
<ul>
<li><p><strong>New project</strong> (fastest): head to <a href="https://aistudio.google.com">aistudio.google.com</a>, spin up a new project, grab a new API key, and swap it into <code>.env</code>. You get a fresh quota immediately.</p>
</li>
<li><p><strong>Enable billing</strong>: billing-enabled projects get much higher limits while keeping the free usage tier. Set up at <a href="https://aistudio.google.com">aistudio.google.com</a>.</p>
</li>
<li><p><strong>Wait</strong>: resets daily at midnight Pacific.</p>
</li>
</ul>
<p>Because <code>agent.js</code> reads the model name from <code>process.env.GEMINI_MODEL</code>, you can also switch models without touching code. Add this to your <code>.env</code> to test with a lighter model:</p>
<pre><code class="language-plaintext">GEMINI_MODEL=gemini-2.0-flash-lite
</code></pre>
<p>Remove the line when your quota resets and the agent goes back to <code>gemini-2.0-flash</code>.</p>
<h3 id="heading-3-error-geminiapikey-is-not-set">3. <code>Error: GEMINI_API_KEY is not set</code></h3>
<p>Nine times out of ten, <code>require('dotenv').config()</code> is either missing or buried below other requires. Drag it to the very top of <code>index.js</code>. Your <code>.env</code> also needs to live at the project root with your actual key in it, not <code>your_api_key_here</code>.</p>
<h3 id="heading-4-googlegenerativeaierror-400-invalidargument">4. <code>GoogleGenerativeAIError: 400 INVALID_ARGUMENT</code></h3>
<p>Almost always a malformed tool schema. Gemini uses uppercase type strings: <code>'OBJECT'</code>, <code>'STRING'</code>, <code>'NUMBER'</code>. JSON Schema uses lowercase. Check your <code>parameters.type</code> values.</p>
<h3 id="heading-5-model-answers-without-calling-any-tools">5. Model Answers Without Calling Any Tools</h3>
<p>The description is too vague or the user's question doesn't match well enough for the model to route it. Add more context to the description about when the tool should be used. The phrase "use this when the user asks about X" directly improves routing accuracy.</p>
<h3 id="heading-6-typeerror-fetch-is-not-a-function">6. <code>TypeError: fetch is not a function</code></h3>
<p>Node 17 and below don't have native <code>fetch</code>. It was added in Node 18. Run <code>node --version</code> to check yours.</p>
<p>If you can't upgrade, install it with <code>npm install node-fetch</code>. Every file that calls <code>fetch</code> then needs <code>const fetch = require('node-fetch')</code> as its first line.</p>
<h3 id="heading-7-tool-works-in-isolation-but-agent-loop-doesnt-call-it">7. Tool Works in Isolation but Agent Loop Doesn't Call it</h3>
<p>The name in <code>toolDefinitions</code> must exactly match the key in <code>toolHandlers</code>. Case matters in JavaScript. <code>get_Weather</code> and <code>get_weather</code> are two different things.</p>
<h3 id="heading-8-exchange-rate-returns-no-rate-found">8. Exchange Rate Returns <code>No rate found</code></h3>
<p>The currency code you passed isn't supported by frankfurter.app. The API covers ~30 major currencies. Check supported codes at <a href="https://www.frankfurter.app/docs/">frankfurter.app</a>.</p>
<h2 id="heading-what-to-build-next">What to Build Next</h2>
<p>The three tools here are a foundation. The loop works the same way regardless of how many tools you add.</p>
<p><strong>Database lookup tool:</strong> A <code>search_products</code> function that queries your PostgreSQL table turns the agent into a product assistant. Point it at your catalog, and it can answer questions about availability, pricing, and specs without you writing any routing logic.</p>
<p><strong>Write tools:</strong> <code>get_*</code> functions make the agent read-only. Add a <code>create_ticket</code> or <code>send_notification</code> function and the agent can take actions: file a support request, trigger a workflow, update a record. Once you add write tools, think carefully about <strong>which queries should require confirmation before executing</strong>.</p>
<p><strong>Memory across sessions:</strong> Right now <code>model.startChat()</code> creates a fresh conversation on every call. Pass a <code>history</code> array when starting the chat and the model remembers prior turns. Store that history in PostgreSQL or Redis keyed to the user ID, and the agent carries context across sessions.</p>
<p><strong>Streaming responses:</strong> For a UI that shows the answer as it types rather than waiting for the full response, replace <code>chat.sendMessage</code> with <code>chat.sendMessageStream</code>. The tool call loop stays the same. Only the final response delivery changes.</p>
<p><strong>Swap the model:</strong> The <code>model</code> string in <code>getGenerativeModel</code> is the only thing that pins you to Gemini 2.0 Flash. <code>gemini-2.0-flash-lite</code> is lighter and faster for simpler queries. For stronger reasoning on complex tasks, run the <code>listModels</code> script from the Troubleshooting section to find the latest available models. The function calling interface is identical across all Gemini models, so swapping takes one line.</p>
<p>The full source code for this article is on GitHub at <a href="https://github.com/ziaongit/nodejs-gemini-agent">github.com/ziaongit/nodejs-gemini-agent</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Multi-Tenant SaaS API with Node.js, RBAC, and Audit Logging ]]>
                </title>
                <description>
                    <![CDATA[ A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created. I pulled up the query logs expecting som ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-multi-tenant-saas-api-with-nodejs-rbac-and-audit-logging/</link>
                <guid isPermaLink="false">6a5e8914bc397f89a942b88b</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ api ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 20:46:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/69793fe0-fe0e-4c9c-839d-12a134f65287.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A colleague asked me to help debug what looked like a permissions issue in their SaaS project management tool. Users were seeing resources they hadn't created.</p>
<p>I pulled up the query logs expecting something subtle. It was not. The list endpoint had no <code>tenant_id</code> filter at all. Every tenant in the database could read every other tenant's projects. The application never threw an error. It just returned whatever was there.</p>
<p>Missing tenant filters don't throw errors. They return the wrong data without any complaint, and nothing in your logs will flag it. I've seen this run in production for weeks before a support ticket pointed anyone at the query logs.</p>
<p>When it does surface, who finds it first matters a lot. A customer noticing it is bad. A compliance auditor noticing it during a SOC 2 review is a different kind of problem.</p>
<p>Isolation built in from the start is a day of work. The time I spent helping a team retrofit it after a compliance review was considerably longer than that, and involved more customer emails than anyone wanted to write.</p>
<p>The stack is Node.js with PostgreSQL. CRUD is the easy part. Tenant isolation, RBAC, and audit logging take more care, and where those checks run in the stack matters. I put all three in middleware, before any route handler fires. A handler that never calls the isolation logic directly can't accidentally skip it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>PostgreSQL 14+</p>
</li>
<li><p>Basic knowledge of Express.js and JWT</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A multi-tenant Express REST API that enforces:</p>
<ol>
<li><p><strong>Tenant isolation:</strong> every database query scopes to the <code>tenant_id</code> from the verified JWT. The client can't influence which tenant the query runs against.</p>
</li>
<li><p><strong>RBAC:</strong> four roles, each with a numeric level (SuperAdmin is highest, Viewer lowest). Middleware checks the level before the handler runs.</p>
</li>
<li><p><strong>Audit logging:</strong> any write or sensitive read appends a row to the audit table. The app can't modify those rows afterward. The database enforces this directly. If a bug in the app tries to UPDATE an audit row, the database refuses it. Application-level enforcement alone can't give you that guarantee.</p>
</li>
<li><p><strong>Per-tenant rate limiting:</strong> request counts in Redis, keyed to the tenant. I've seen IP-based limiting break an enterprise rollout when fifty users came through a single corporate proxy.</p>
</li>
<li><p><strong>Tenant isolation tests:</strong> a dedicated test file that proves cross-tenant data can't leak. Wire it into CI and it catches broken isolation before it ships.</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-multi-tenancy-works">How Multi-Tenancy Works</a></p>
</li>
<li><p><a href="#heading-architecture-overview">Architecture Overview</a></p>
</li>
<li><p><a href="#heading-database-schema-design">Database Schema Design</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</a></p>
</li>
<li><p><a href="#heading-auth-and-rbac-middleware">Auth and RBAC Middleware</a></p>
</li>
<li><p><a href="#heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</a></p>
</li>
<li><p><a href="#heading-audit-logging-service">Audit Logging Service</a></p>
</li>
<li><p><a href="#heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</a></p>
</li>
<li><p><a href="#heading-building-the-routes">Building the Routes</a></p>
</li>
<li><p><a href="#heading-testing-tenant-isolation">Testing Tenant Isolation</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-multi-tenancy-works">How Multi-Tenancy Works</h2>
<p>This tutorial uses a <strong>shared database with row-level isolation</strong>: a <code>tenant_id</code> column on every table, a filter on every query. The database holds everyone's data together. The application decides what each tenant can see.</p>
<p>Two other approaches exist: schema-per-tenant and database-per-tenant. I've talked to teams on schema-per-tenant who ended up spending more engineering time on migration tooling than on their actual product. Database-per-tenant gives stronger guarantees but a connection pool that balloons with every new customer signup.</p>
<p>Neither scales cheaply. Row-level isolation scales further than most teams expect. The ones I know who moved off it did so years in, usually under specific regulatory pressure, not because the approach stopped working.</p>
<p>The one thing in this design that can't be optional: <code>tenant_id</code> <strong>must always come from the verified JWT.</strong> Not from the request body, not from the URL. Users control what they put in both of those. They don't control what gets signed into a JWT on your server.</p>
<h2 id="heading-architecture-overview">Architecture Overview</h2>
<pre><code class="language-plaintext">HTTP Request
     │
     ▼
┌─────────────────────────────────────────┐
│           Express Middleware Stack       │
│                                         │
│  1. Rate Limiter (per tenant_id)        │
│  2. Auth Middleware (verify JWT)        │
│     └─► Extracts: userId, tenantId,    │
│          role, permissions              │
│  3. RBAC Middleware (check role)        │
└──────────────┬──────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────┐
│           Route Handler                  │
│                                         │
│  1. Call Repository (tenant-safe query) │
│  2. Call Audit Service (fire &amp; forget)  │
│  3. Return response                     │
└──────────────┬──────────────────────────┘
               │
     ┌─────────┴──────────┐
     ▼                    ▼
┌─────────┐        ┌────────────┐
│ Projects│        │ Audit Logs │
│  Table  │        │   Table    │
│(+tenant)│        │(append only│
└─────────┘        └────────────┘
</code></pre>
<p>Rate limiting, auth, and RBAC all run before any handler sees the request. Writes pass through the audit service. The repository takes <code>tenantId</code> from <code>req.user</code> and the handler never touches tenant scoping directly, so there's no path around it.</p>
<h2 id="heading-database-schema-design">Database Schema Design</h2>
<pre><code class="language-sql">-- Tenants table
CREATE TABLE tenants (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name        VARCHAR(255) NOT NULL,
  plan        VARCHAR(50) NOT NULL DEFAULT 'free', -- 'free', 'pro', 'enterprise'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Users table
CREATE TABLE users (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email       VARCHAR(255) NOT NULL,
  role        VARCHAR(50) NOT NULL DEFAULT 'Member', -- 'SuperAdmin','TenantAdmin','Member','Viewer'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email)
);

CREATE INDEX idx_users_tenant ON users(tenant_id);

-- Projects table (example resource — replace with your domain entity)
CREATE TABLE projects (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name        VARCHAR(255) NOT NULL,
  description TEXT,
  created_by  UUID NOT NULL REFERENCES users(id),
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- Audit log table (append-only — never UPDATE or DELETE rows here)
CREATE TABLE audit_logs (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   UUID NOT NULL,
  user_id     UUID NOT NULL,
  user_email  TEXT NOT NULL,
  user_role   TEXT NOT NULL,        -- role at time of action
  action      TEXT NOT NULL,        -- 'CREATE', 'UPDATE', 'DELETE', 'VIEW'
  resource    TEXT NOT NULL,        -- table name
  resource_id TEXT,
  old_values  JSONB,
  new_values  JSONB,
  ip_address  INET,
  user_agent  TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_tenant ON audit_logs(tenant_id);
CREATE INDEX idx_audit_created ON audit_logs(created_at DESC);

-- Protect audit log at database level
-- Use a DO block so this runs safely in Docker where app_user is the superuser
DO $$
BEGIN
  IF current_user &lt;&gt; 'app_user' THEN
    REVOKE DELETE, UPDATE ON audit_logs FROM app_user;
  END IF;
END $$;
</code></pre>
<p>The <code>REVOKE</code> matters. Application bugs happen. If something in your codebase accidentally tries to UPDATE an audit row, you want the database to refuse it outright, not silently comply.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<pre><code class="language-bash">mkdir nodejs-multitenant-saas-api
cd nodejs-multitenant-saas-api
npm init -y
npm install express pg jsonwebtoken bcryptjs express-rate-limit rate-limit-redis ioredis dotenv
npm install --save-dev jest supertest
</code></pre>
<h3 id="heading-starting-postgresql-and-redis-with-docker">Starting PostgreSQL and Redis with Docker</h3>
<p>Skip the local installs. One <code>docker-compose.yml</code> in the project root brings up both PostgreSQL and Redis:</p>
<pre><code class="language-yaml">services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: saas_api
      POSTGRES_USER: app_user
      POSTGRES_PASSWORD: app_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./schema.sql:/docker-entrypoint-initdb.d/01_schema.sql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  postgres_data:
</code></pre>
<p>That <code>schema.sql</code> mount runs your SQL automatically when the container first starts. No psql required.</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p><code>.env</code> in the project root:</p>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://app_user:app_password@localhost:5432/saas_api
REDIS_URL=redis://localhost:6379
JWT_SECRET=your_random_secret_here
PORT=3000
NODE_ENV=development
</code></pre>
<p>Don't type a <code>JWT_SECRET</code> by hand. Run this to generate one:</p>
<pre><code class="language-bash">node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
</code></pre>
<p>File structure:</p>
<pre><code class="language-plaintext">nodejs-multitenant-saas-api/
├── src/
│   ├── middleware/
│   │   ├── auth.js          # JWT verification + tenant extraction
│   │   ├── rbac.js          # Role enforcement
│   │   └── rateLimiter.js   # Per-tenant rate limiting
│   ├── services/
│   │   └── auditService.js  # Append-only audit logger
│   ├── repositories/
│   │   └── projectRepo.js   # Tenant-safe DB queries
│   ├── routes/
│   │   └── projects.js      # Route handlers
│   └── utils/
│       └── token.js         # JWT token generation
├── db/
│   ├── index.js             # PostgreSQL pool
│   └── redis.js             # Redis client
├── docker-compose.yml
├── app.js
├── server.js
└── tests/
    └── tenantIsolation.test.js
</code></pre>
<h3 id="heading-boilerplate-files">Boilerplate Files</h3>
<p>There are four files the tutorial doesn't cover in detail, but the test file needs all of them to run:</p>
<pre><code class="language-javascript">// db/index.js
const { Pool } = require('pg');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

pool.on('error', (err) =&gt; console.error('PostgreSQL error:', err.message));

module.exports = { pool };
</code></pre>
<pre><code class="language-javascript">// db/redis.js
const Redis = require('ioredis');

const redisClient = new Redis(process.env.REDIS_URL);

redisClient.on('error', (err) =&gt; console.error('Redis error:', err.message));

module.exports = { redisClient };
</code></pre>
<pre><code class="language-javascript">// app.js
require('dotenv').config();
const express = require('express');
const projectsRouter = require('./src/routes/projects');

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

app.use('/api/projects', projectsRouter);

// Global error handler — must have 4 parameters to be recognised by Express
app.use((err, req, res, next) =&gt; {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<pre><code class="language-javascript">// server.js
const app = require('./app');

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p><code>bcryptjs</code> is included for a login endpoint with proper password hashing. That part isn't covered here, but the GitHub repo has a working <code>/api/auth/login</code> example.</p>
<h2 id="heading-jwt-design-for-multi-tenancy">JWT Design for Multi-Tenancy</h2>
<p>Both <code>tenantId</code> and <code>role</code> go into the JWT payload. Everything downstream reads from these two fields. Get them wrong, and nothing behaves correctly.</p>
<pre><code class="language-javascript">// Example JWT payload
{
  "userId": "usr_abc123",
  "tenantId": "ten_xyz789",
  "email": "alice@acme.com",
  "role": "TenantAdmin",
  "iat": 1720000000,
  "exp": 1720086400
}
</code></pre>
<p>The roles in order of privilege:</p>
<ul>
<li><p><strong>SuperAdmin:</strong> cross-tenant access for your internal team only</p>
</li>
<li><p><strong>TenantAdmin:</strong> full access within their tenant</p>
</li>
<li><p><strong>Member:</strong> read and write within their tenant</p>
</li>
<li><p><strong>Viewer:</strong> read-only within their tenant</p>
</li>
</ul>
<p>Generate a token (used for testing and your auth endpoint):</p>
<pre><code class="language-javascript">// src/utils/token.js
const jwt = require('jsonwebtoken');

function generateToken({ userId, tenantId, email, role }) {
  return jwt.sign(
    { userId, tenantId, email, role },
    process.env.JWT_SECRET,
    { expiresIn: '24h' }
  );
}

module.exports = { generateToken };
</code></pre>
<h2 id="heading-auth-and-rbac-middleware">Auth and RBAC Middleware</h2>
<p>The auth middleware does two things: verifies the JWT signature and extracts the tenant context into <code>req.user</code>.</p>
<p>That second part is what the entire system depends on. Every query downstream reads <code>req.user.tenantId</code>. The client has no say in what that value is. They send a token the server signed, and the server reads back what it put in.</p>
<pre><code class="language-javascript">// src/middleware/auth.js
const jwt = require('jsonwebtoken');

function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // tenantId always comes from the verified token — never req.body or req.params
    req.user = {
      userId:   decoded.userId,
      tenantId: decoded.tenantId,
      email:    decoded.email,
      role:     decoded.role,
    };

    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

module.exports = { authMiddleware };
</code></pre>
<p>The RBAC middleware is separate from auth by design. Auth runs on every route. Role enforcement only applies where a minimum role is required. You pass the allowed roles to <code>requireRole()</code> and it compares the user's level against the hierarchy. A Viewer trying to delete something hits the 403 before the handler ever runs.</p>
<pre><code class="language-javascript">// src/middleware/rbac.js
const ROLE_HIERARCHY = {
  SuperAdmin:   4,
  TenantAdmin:  3,
  Member:       2,
  Viewer:       1,
};

// requireRole('TenantAdmin') — user must be TenantAdmin or higher
function requireRole(...roles) {
  return (req, res, next) =&gt; {
    const userLevel = ROLE_HIERARCHY[req.user?.role] ?? 0;
    const requiredLevel = Math.min(...roles.map(r =&gt; ROLE_HIERARCHY[r] ?? 999));

    if (userLevel &lt; requiredLevel) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: roles,
        current: req.user?.role,
      });
    }

    next();
  };
}

module.exports = { requireRole };
</code></pre>
<h2 id="heading-the-tenant-safe-repository-layer">The Tenant-Safe Repository Layer</h2>
<p>Isolation lives here. Every function takes <code>tenantId</code> as a required argument, pulled from <code>req.user</code> by the handler. There's no way to call these without providing a tenant scope. I've watched teams try to handle this with a URL parameter instead (<code>GET /api/projects?tenantId=xyz</code>) and call it isolated. It is not. Any client sends whatever it wants in a query string.</p>
<pre><code class="language-javascript">// src/repositories/projectRepo.js
const { pool } = require('../../db');

// List all projects for a tenant — tenantId is ALWAYS from the JWT
async function listProjects(tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE tenant_id = $1
     ORDER BY created_at DESC`,
    [tenantId]
  );
  return result.rows;
}

// Get a single project — returns null if it belongs to a different tenant
// NOTE: Returns 404 (not 403) intentionally — don't reveal the resource exists
async function getProject(id, tenantId) {
  const result = await pool.query(
    `SELECT id, name, description, created_by, created_at
     FROM projects
     WHERE id = $1 AND tenant_id = $2`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

async function createProject({ tenantId, name, description, createdBy }) {
  const result = await pool.query(
    `INSERT INTO projects (tenant_id, name, description, created_by)
     VALUES ($1, $2, $3, $4)
     RETURNING *`,
    [tenantId, name, description, createdBy]
  );
  return result.rows[0];
}

async function updateProject(id, tenantId, updates) {
  const result = await pool.query(
    `UPDATE projects
     SET name = COALESCE($3, name),
         description = COALESCE($4, description),
         updated_at = NOW()
     WHERE id = $1 AND tenant_id = $2
     RETURNING *`,
    [id, tenantId, updates.name, updates.description]
  );
  return result.rows[0] || null;
}

async function deleteProject(id, tenantId) {
  const result = await pool.query(
    `DELETE FROM projects WHERE id = $1 AND tenant_id = $2 RETURNING id`,
    [id, tenantId]
  );
  return result.rows[0] || null;
}

module.exports = { listProjects, getProject, createProject, updateProject, deleteProject };
</code></pre>
<p>Notice what <code>getProject</code> does when Tenant A tries to fetch a Tenant B resource. The query runs with Tenant A's <code>tenantId</code>. The condition <code>id = $1 AND tenant_id = $2</code> matches nothing, <code>null</code> comes back, and the handler sends a <code>404</code>. Not a <code>403</code>. A 403 tells the caller the resource exists, but they can't access it, which is information they shouldn't have.</p>
<h2 id="heading-audit-logging-service">Audit Logging Service</h2>
<pre><code class="language-javascript">// src/services/auditService.js
const { pool } = require('../../db');

async function log({
  tenantId,
  userId,
  userEmail,
  userRole,          // role at time of action — roles change, log should not
  action,            // 'CREATE' | 'UPDATE' | 'DELETE' | 'VIEW'
  resource,          // table name
  resourceId = null,
  oldValues = null,
  newValues = null,
  ipAddress = null,
  userAgent = null,
}) {
  const query = `
    INSERT INTO audit_logs
      (tenant_id, user_id, user_email, user_role, action, resource,
       resource_id, old_values, new_values, ip_address, user_agent)
    VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
  `;

  const values = [
    tenantId, userId, userEmail, userRole, action, resource,
    resourceId,
    oldValues  ? JSON.stringify(oldValues)  : null,
    newValues  ? JSON.stringify(newValues)  : null,
    ipAddress,
    userAgent,
  ];

  // Fire-and-forget — audit logging must never block or fail a user request
  pool.query(query, values).catch((err) =&gt; {
    console.error('[AuditService] Failed to write log:', err.message);
  });
}

module.exports = { log };
</code></pre>
<p>Capturing <code>userRole</code> at write time matters more than it looks. User roles change after the fact: someone gets demoted, a permission is revoked. If the log only records the user ID, you lose the context of what privilege they held when the action happened. Store the role at the time of the action, and you always know.</p>
<h2 id="heading-per-tenant-rate-limiting">Per-Tenant Rate Limiting</h2>
<p>IP-based rate limiting breaks down in SaaS. A corporate customer might route hundreds of users through a single NAT gateway, sharing one IP address. One heavy tenant throttles everyone else on that address.</p>
<p>I've watched teams discover this the hard way when an enterprise customer suddenly floods the API, and their other tenants start getting 429s with no explanation. Scope limits to <code>tenant_id</code> instead.</p>
<pre><code class="language-javascript">// src/middleware/rateLimiter.js
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { redisClient } = require('../../db/redis');

// Rate limits by plan — extend as needed
const PLAN_LIMITS = {
  free:       { max: 100,  windowMs: 15 * 60 * 1000 }, // 100 req / 15 min
  pro:        { max: 500,  windowMs: 15 * 60 * 1000 }, // 500 req / 15 min
  enterprise: { max: 2000, windowMs: 15 * 60 * 1000 }, // 2000 req / 15 min
};

function createTenantRateLimiter(plan = 'free') {
  const limits = PLAN_LIMITS[plan] || PLAN_LIMITS.free;

  return rateLimit({
    windowMs: limits.windowMs,
    max: limits.max,
    // Key = tenant_id from verified JWT — NOT the IP address
    keyGenerator: (req) =&gt; `tenant:${req.user?.tenantId || req.ip}`,
    store: new RedisStore({
      sendCommand: (...args) =&gt; redisClient.call(...args),
    }),
    handler: (req, res) =&gt; {
      res.status(429).json({
        error: 'Too many requests',
        retryAfter: Math.ceil(limits.windowMs / 1000),
      });
    },
  });
}

// Default limiter for all API routes
const defaultLimiter = createTenantRateLimiter('free');

module.exports = { defaultLimiter, createTenantRateLimiter };
</code></pre>
<h2 id="heading-building-the-routes">Building the Routes</h2>
<p>This is where everything connects. Auth and rate limiting apply to the whole router. Role checks go on individual routes. The audit log fires after every write. <code>tenantId</code> never comes from the request body or URL. <code>req.user.tenantId</code> is the only source, set by the auth middleware from the verified token, so there's no path around it.</p>
<p>One practical detail for Express 4: it doesn't catch async errors automatically. Every handler wraps its logic in try/catch and passes failures to <code>next(err)</code>. Skip that and an unhandled promise rejection returns a blank 500 with no log entry and no audit trail. The comment at the top of the router is a reminder that the pattern is intentional.</p>
<pre><code class="language-javascript">// src/routes/projects.js
const express = require('express');
const { authMiddleware }  = require('../middleware/auth');
const { requireRole }     = require('../middleware/rbac');
const { defaultLimiter }  = require('../middleware/rateLimiter');
const audit               = require('../services/auditService');
const repo                = require('../repositories/projectRepo');

const router = express.Router();

// All routes require authentication
router.use(authMiddleware);
router.use(defaultLimiter);

// Express 4 does not catch async errors automatically.
// Every handler must wrap await calls in try/catch and pass errors to next().
// Without this, an unhandled promise rejection silently returns 500
// with no useful message and no audit log entry.

// GET /api/projects — list all (Viewer and above)
router.get('/', async (req, res, next) =&gt; {
  try {
    const projects = await repo.listProjects(req.user.tenantId);

    audit.log({
      tenantId:   req.user.tenantId,
      userId:     req.user.userId,
      userEmail:  req.user.email,
      userRole:   req.user.role,
      action:     'VIEW',
      resource:   'projects',
      ipAddress:  req.ip,
      userAgent:  req.headers['user-agent'],
    });

    res.json(projects);
  } catch (err) {
    next(err);
  }
});

// GET /api/projects/:id — single project (Viewer and above)
router.get('/:id', async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });
    res.json(project);
  } catch (err) {
    next(err);
  }
});

// POST /api/projects — create (Member and above)
router.post('/', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const { name, description } = req.body;
    if (!name) return res.status(400).json({ error: 'name is required' });

    const project = await repo.createProject({
      tenantId:    req.user.tenantId,
      name,
      description,
      createdBy:   req.user.userId,
    });

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'CREATE',
      resource:    'projects',
      resourceId:  project.id,
      newValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.status(201).json(project);
  } catch (err) {
    next(err);
  }
});

// PUT /api/projects/:id — update (Member and above)
router.put('/:id', requireRole('Member', 'TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const oldProject = await repo.getProject(req.params.id, req.user.tenantId);
    if (!oldProject) return res.status(404).json({ error: 'Not found' });

    const updated = await repo.updateProject(req.params.id, req.user.tenantId, req.body);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'UPDATE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   oldProject,
      newValues:   updated,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json(updated);
  } catch (err) {
    next(err);
  }
});

// DELETE /api/projects/:id — TenantAdmin and above only
router.delete('/:id', requireRole('TenantAdmin', 'SuperAdmin'), async (req, res, next) =&gt; {
  try {
    const project = await repo.getProject(req.params.id, req.user.tenantId);
    if (!project) return res.status(404).json({ error: 'Not found' });

    await repo.deleteProject(req.params.id, req.user.tenantId);

    audit.log({
      tenantId:    req.user.tenantId,
      userId:      req.user.userId,
      userEmail:   req.user.email,
      userRole:    req.user.role,
      action:      'DELETE',
      resource:    'projects',
      resourceId:  req.params.id,
      oldValues:   project,
      ipAddress:   req.ip,
      userAgent:   req.headers['user-agent'],
    });

    res.json({ deleted: true });
  } catch (err) {
    next(err);
  }
});

module.exports = router;
</code></pre>
<h2 id="heading-testing-tenant-isolation">Testing Tenant Isolation</h2>
<p>Skip the isolation tests and you're flying blind. The application keeps running, nothing throws an error, but two customers are reading each other's data.</p>
<p>I've watched this sit undetected in production for months because nothing actually broke. The wrong data just showed up quietly. Automated tests on every pull request are the only reliable way to catch it early.</p>
<pre><code class="language-javascript">// tests/tenantIsolation.test.js
require('dotenv').config();  // must be first — loads DATABASE_URL and REDIS_URL
const request = require('supertest');
const app     = require('../app');
const { generateToken } = require('../src/utils/token');
const { pool }        = require('../db');
const { redisClient } = require('../db/redis');

// Test fixture: two isolated tenants, one project in Tenant B
async function seedTestData() {
  // Clean up from any previous run to avoid unique-constraint failures
  await pool.query(`DELETE FROM projects WHERE name LIKE 'TEST-%'`);
  await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);

  const tenantA = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant A', 'pro') RETURNING id`
  )).rows[0].id;

  const tenantB = (await pool.query(
    `INSERT INTO tenants (name, plan) VALUES ('Tenant B', 'pro') RETURNING id`
  )).rows[0].id;

  const userA = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'usera@a.com', 'Member') RETURNING id`,
    [tenantA]
  )).rows[0].id;

  // userB owns the project in Tenant B — satisfies the created_by FK constraint
  const userB = (await pool.query(
    `INSERT INTO users (tenant_id, email, role) VALUES ($1, 'userb@b.com', 'Member') RETURNING id`,
    [tenantB]
  )).rows[0].id;

  const projectB = (await pool.query(
    `INSERT INTO projects (tenant_id, name, created_by)
     VALUES ($1, 'TEST-Secret Project', $2) RETURNING id`,
    [tenantB, userB]
  )).rows[0].id;

  return { tenantA, tenantB, userA, projectB };
}

describe('Tenant Isolation', () =&gt; {
  let data;

  beforeAll(async () =&gt; {
    data = await seedTestData();
  });

  afterAll(async () =&gt; {
    await pool.query(`DELETE FROM tenants WHERE name IN ('Tenant A', 'Tenant B')`);
    await pool.end();
    await redisClient.quit();  // close Redis connection so Jest exits cleanly
  });

  test('Tenant A user cannot read Tenant B project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,   // ← Tenant A token
      email:    'usera@a.com',
      role:     'Member',
    });

    const res = await request(app)
      .get(`/api/projects/${data.projectB}`)  // ← Tenant B's project ID
      .set('Authorization', `Bearer ${token}`);

    // Must be 404, not 200 or 403
    expect(res.status).toBe(404);
  });

  test('Tenant A user cannot list Tenant B projects', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'TenantAdmin',
    });

    const res = await request(app)
      .get('/api/projects')
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(200);
    // Response must contain zero Tenant B projects
    const names = res.body.map(p =&gt; p.name);
    expect(names).not.toContain('TEST-Secret Project');
  });

  test('Viewer cannot delete a project', async () =&gt; {
    const token = generateToken({
      userId:   data.userA,
      tenantId: data.tenantA,
      email:    'usera@a.com',
      role:     'Viewer',         // ← Viewer role
    });

    const res = await request(app)
      .delete(`/api/projects/${data.projectB}`)
      .set('Authorization', `Bearer ${token}`);

    expect(res.status).toBe(403);
  });
});
</code></pre>
<p>Run the tests:</p>
<pre><code class="language-bash">npm test
</code></pre>
<p>Three tests, three boundaries confirmed. Wire these into CI so they run on every pull request. A future refactor that quietly drops the <code>tenant_id</code> filter will get caught before it ships.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<h3 id="heading-tenant-a-can-see-tenant-bs-data">Tenant A can see Tenant B's data</h3>
<p>One query is missing the <code>AND tenant_id = $N</code> clause. Search every repository file for <code>SELECT</code> statements and check each one. It's almost always this.</p>
<h3 id="heading-403-forbidden-on-a-route-that-should-be-accessible"><code>403 Forbidden</code> on a route that should be accessible</h3>
<p>The role string in the JWT doesn't match what <code>requireRole()</code> is checking. Check the exact string in the token payload. <code>'member'</code> and <code>'Member'</code> aren't the same thing. Paste your token into jwt.io and look at the role field directly.</p>
<h3 id="heading-rate-limiter-isnt-working">Rate limiter isn't working</h3>
<p>Redis is probably not connected. Log <code>redisClient.status</code> before the server starts. If it's not <code>ready</code>, the limiter has fallen back to in-memory, which means restarts reset all counters and tenant-scoped limiting stops working.</p>
<h3 id="heading-audit-log-table-growing-very-large">Audit log table growing very large</h3>
<p>Expected behaviour. Audit tables grow, that's the point. Once it gets large, ship rows older than a year to S3 or Azure Blob and keep querying against a smaller hot table. Most compliance requirements want at least 12 months of accessible logs anyway. Just don't DELETE from the table itself.</p>
<h3 id="heading-jwtverify-throws-jsonwebtokenerror-invalid-signature"><code>jwt.verify</code> throws <code>JsonWebTokenError: invalid signature</code></h3>
<p>The secret that signed the token doesn't match <code>JWT_SECRET</code> in the environment where you're verifying it. This comes up most when switching between environments or when a second service has a different value in its <code>.env</code>. Every service that calls <code>jwt.verify</code> needs the exact same secret. Copy it across, don't retype it.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The system you've built: row-level isolation in the repository, role checks before the handler runs, an audit table the app can't touch, and rate limits per tenant. That's the whole thing.</p>
<p>The tests are what I see dropped most often. Teams build the isolation, ship it, and never write something that actually proves cross-tenant data can't leak. Then a query gets refactored six months later and the <code>tenant_id</code> filter quietly disappears. CI catches it. Manual code review rarely does.</p>
<p>Schema-per-tenant comes up eventually if your product grows large enough. But not at the start. Row-level isolation handles more scale than most teams will ever hit, and it costs a fraction of the operational overhead.</p>
<p>The full working code is available on GitHub: <a href="https://github.com/ziaongit/nodejs-multitenant-saas-api">nodejs-multitenant-saas-api</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Manage Secrets Securely with Azure Key Vault in Node.js ]]>
                </title>
                <description>
                    <![CDATA[ Last year a client called me about exactly this. Someone ran git log -p on a hunch and found a .env committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-manage-secrets-securely-with-azure-key-vault-in-node-js/</link>
                <guid isPermaLink="false">6a5e27b295e748bed9510853</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Azure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 13:50:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/5491b408-9c6b-4d4d-a53e-215119fb2d97.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Last year a client called me about exactly this. Someone ran <code>git log -p</code> on a hunch and found a <code>.env</code> committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all still active. All still in production.</p>
<p>IBM's 2024 breach cost report put the average data breach at <strong>$4.88 million</strong> — and that's the average, not the worst cases.</p>
<p>Exposed credentials are consistently near the top of root causes. GitHub found over a million secrets leaked in public repos in 2023 alone, before you even count the private ones nobody ever discovered.</p>
<p>It's not a people problem. The developers I've worked with aren't careless — the architecture is just set up to fail them. A <code>.env</code> file gets committed once by accident. Credentials get copied and pasted into a Slack message to unblock a teammate. A Docker image gets published with secrets baked into a layer. A server gets shut down, and nobody rotates the credentials it was holding.</p>
<p>Azure Key Vault solves this differently. Your application fetches credentials at runtime from a centralized, encrypted service — the <code>.env</code> file stops being a liability because it stops holding anything worth stealing.</p>
<p>What you'll build is a Node.js Express API that fetches every secret from Azure Key Vault at startup. No passwords in the code. When someone quits, there's nothing in the repo to rotate. The <code>.env</code> ends up with one line — the vault name.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>An Azure account (free tier works)</p>
</li>
<li><p>Azure CLI installed and logged in (<code>az login</code>)</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Docker (optional — only needed for the local database test section)</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A Node.js Express API that:</p>
<ol>
<li><p>Connects to PostgreSQL using credentials fetched from Key Vault at startup</p>
</li>
<li><p>Uses Managed Identity for authentication — no client secrets or passwords anywhere</p>
</li>
<li><p>Caches secrets in memory, so Key Vault isn't called on every request</p>
</li>
<li><p>Works locally via Azure CLI auth and in production via Managed Identity — same code, zero changes</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-the-architecture-works">How the Architecture Works</a></p>
</li>
<li><p><a href="#heading-what-is-azure-key-vault">What Is Azure Key Vault?</a></p>
</li>
<li><p><a href="#heading-set-up-the-key-vault">Set Up the Key Vault</a></p>
</li>
<li><p><a href="#heading-create-the-nodejs-project">Create the Node.js Project</a></p>
</li>
<li><p><a href="#heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</a></p>
</li>
<li><p><a href="#heading-cache-secrets-at-startup">Cache Secrets at Startup</a></p>
</li>
<li><p><a href="#heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</a></p>
</li>
<li><p><a href="#heading-test-locally">Test Locally</a></p>
</li>
<li><p><a href="#heading-deploy-to-azure-app-service">Deploy to Azure App Service</a></p>
</li>
<li><p><a href="#heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</a></p>
</li>
<li><p><a href="#heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-the-architecture-works">How the Architecture Works</h2>
<p>Before writing any code, it helps to see the full picture:</p>
<pre><code class="language-plaintext"> LOCAL DEVELOPMENT
.-------------------------------------------------------.
|                                                        |
|   [Node.js App]                                        |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; az login session       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'

 PRODUCTION (Azure)
.-------------------------------------------------------.
|                                                        |
|   [Azure App Service]                                  |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; Managed Identity       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'
</code></pre>
<p>Both environments run the exact same code. <code>DefaultAzureCredential</code> figures out where it is — locally it picks up your <code>az login</code> session, on Azure it uses Managed Identity. You don't switch config files and you don't manage credentials. It just works.</p>
<h2 id="heading-what-is-azure-key-vault">What Is Azure Key Vault?</h2>
<p>Azure Key Vault is Microsoft's managed secret store — it handles secrets, keys, and certificates. For this tutorial, we're only using the secrets part: database passwords, API keys, JWT signing keys, anything your app needs to run but has no business being in your Git history.</p>
<p>Compared to <code>.env</code> files, the practical differences are worth understanding before you write any code.</p>
<p>Rotation is the one I notice most on real projects. Update a secret in Key Vault and every app picks it up on the next restart — no hunting down five different environment configs across staging and production.</p>
<p>Access control is the other big one. Each application only gets permission to read the secrets it actually needs. If one service gets compromised, it can't read credentials belonging to other services.</p>
<p>And every read gets logged. When something goes wrong — and eventually something will — you can see exactly which app accessed which secret, and when. That log is what auditors actually want to see.</p>
<p>I've sat in enough security reviews to know that "we use <code>.env</code> files and tell people not to commit them" doesn't satisfy an auditor. SOC 2, HIPAA, GDPR — they all want demonstrable controls. A vault with an access log is demonstrable.</p>
<h2 id="heading-set-up-the-key-vault">Set Up the Key Vault</h2>
<p>Run these commands. The vault name has to be globally unique across all of Azure — not just your own subscription — so pick something specific. Letters, numbers, and hyphens, 3 to 24 characters.</p>
<pre><code class="language-bash"># Create a resource group (skip if you already have one)
az group create \
  --name keyvault-demo-rg \
  --location eastus

# Create the Key Vault (RBAC enabled by default — required for the role assignment later)
az keyvault create \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --location eastus

# Grant yourself permission to manage secrets (required with RBAC — creators are not auto-assigned)
az role assignment create \
  --role "Key Vault Secrets Officer" \
  --assignee-object-id $(az ad signed-in-user show --query id -o tsv) \
  --scope $(az keyvault show \
    --name your-vault-name \
    --resource-group keyvault-demo-rg \
    --query id -o tsv)

# Add your secrets
az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-HOST" \
  --value "your-db-host.postgres.database.azure.com"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "your-super-secret-password"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "JWT-SECRET" \
  --value "your-jwt-signing-secret"
</code></pre>
<p>Verify the secrets were stored:</p>
<pre><code class="language-bash">az keyvault secret list --vault-name your-vault-name --query "[].name" -o tsv
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">DB-HOST
DB-PASSWORD
JWT-SECRET
</code></pre>
<h2 id="heading-create-the-nodejs-project">Create the Node.js Project</h2>
<p>Set up the project structure:</p>
<pre><code class="language-bash">mkdir nodejs-azure-keyvault
cd nodejs-azure-keyvault
npm init -y
npm install express pg jsonwebtoken @azure/keyvault-secrets @azure/identity dotenv
</code></pre>
<p>The two Azure packages do all the work:</p>
<ul>
<li><p><code>@azure/keyvault-secrets</code> — connects to your vault and pulls secrets out</p>
</li>
<li><p><code>@azure/identity</code> — handles auth. Locally, it uses your <code>az login</code> session, in production, it switches to Managed Identity automatically</p>
</li>
</ul>
<p>Add a start script to <code>package.json</code>:</p>
<pre><code class="language-bash">npm pkg set scripts.start="node server.js"
</code></pre>
<p>Create the following file structure:</p>
<pre><code class="language-plaintext">nodejs-azure-keyvault/
|-- src/
|   |-- config/
|   |   `-- secrets.js   # Key Vault client and secret loader
|   |-- db/
|   |   `-- index.js     # PostgreSQL pool using secrets
|   `-- routes/
|       `-- users.js     # Example route
|-- app.js               # Express app
`-- server.js            # Entry point -- loads secrets first
</code></pre>
<h2 id="heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</h2>
<p>Create the secrets config file:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

async function getSecret(name) {
  const secret = await client.getSecret(name);
  return secret.value;
}

module.exports = { getSecret };
</code></pre>
<p><code>DefaultAzureCredential</code> is the most important part of this setup. It tries a chain of authentication methods in order:</p>
<ol>
<li><p>Environment variables (for CI/CD pipelines)</p>
</li>
<li><p>Azure CLI credentials (for local development — <code>az login</code>)</p>
</li>
<li><p>Managed Identity (for deployed apps on Azure)</p>
</li>
</ol>
<p>This means the exact same code works locally and in production with zero changes. Locally, it uses your <code>az login</code> session. In production, it uses the app's Managed Identity. You never touch credentials.</p>
<h2 id="heading-cache-secrets-at-startup">Cache Secrets at Startup</h2>
<p>Calling Key Vault on every request adds latency and costs money. Load all secrets once at startup and cache them in memory. Replace <code>src/config/secrets.js</code> with this complete version:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

// In-memory cache
const cache = {};

async function getSecret(name) {
  if (cache[name]) return cache[name];
  const secret = await client.getSecret(name);
  cache[name] = secret.value;
  return secret.value;
}

async function loadAllSecrets() {
  console.log('Loading secrets from Azure Key Vault...');
  const secretNames = ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'];

  await Promise.all(
    secretNames.map(async (name) =&gt; {
      cache[name] = await getSecret(name);
      console.log(`  ✓ ${name} loaded`);
    })
  );

  console.log('All secrets loaded successfully.');
}

function getFromCache(name) {
  if (!cache[name]) throw new Error(`Secret "${name}" not loaded. Did loadAllSecrets() run?`);
  return cache[name];
}

module.exports = { loadAllSecrets, getFromCache };
</code></pre>
<p>The <code>loadAllSecrets</code> function runs once when the application starts. After that, all secrets are served from the in-memory cache with zero latency and zero Key Vault calls.</p>
<h2 id="heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</h2>
<p>Set up the database connection using the cached secrets:</p>
<pre><code class="language-javascript">// src/db/index.js
const { Pool } = require('pg');
const { getFromCache } = require('../config/secrets');

let pool;

function getPool() {
  if (!pool) {
    pool = new Pool({
      host:     getFromCache('DB-HOST'),
      database: process.env.DB_NAME || 'myapp',
      user:     process.env.DB_USER || 'dbadmin',
      password: getFromCache('DB-PASSWORD'),
      port:     parseInt(process.env.DB_PORT || '5432'),
      ssl:      process.env.NODE_ENV === 'production'
                  ? { rejectUnauthorized: false }
                  : false,
    });

    pool.on('error', (err) =&gt; {
      console.error('Unexpected database pool error:', err.message);
    });
  }

  return pool;
}

module.exports = { getPool };
</code></pre>
<p>Notice the distinction: <code>DB-HOST</code> and <code>DB-PASSWORD</code> come from Key Vault because they're sensitive. The database name, username, and port are not — they don't need to be protected, so they use environment variables with sensible defaults. Key Vault is for credentials, not all configuration.</p>
<p>The SSL flag is environment-aware: forced on in production, off locally so Docker connections work without a certificate. The <code>rejectUnauthorized: false</code> setting accepts Azure Database for PostgreSQL's certificate without verifying the CA chain — this is standard for Azure-managed databases. For stricter environments, you can download the Azure root CA and pass it via the <code>ca</code> option in the pool config instead.</p>
<p>Create a sample route that uses JWT verification with the secret from Key Vault:</p>
<pre><code class="language-javascript">// src/routes/users.js
const express = require('express');
const jwt     = require('jsonwebtoken');
const { getFromCache } = require('../config/secrets');
const { getPool }      = require('../db');

const router = express.Router();

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// GET /api/users — list users (authenticated)
router.get('/', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users ORDER BY created_at DESC LIMIT 20'
    );
    res.json(result.rows);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// GET /api/users/:id — single user (authenticated)
router.get('/:id', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users WHERE id = $1',
      [req.params.id]
    );
    if (!result.rows[0]) return res.status(404).json({ error: 'User not found' });
    res.json(result.rows[0]);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

module.exports = router;
</code></pre>
<p>Notice the error handler returns <code>'Internal server error'</code> instead of <code>err.message</code>. Database errors are surprisingly chatty — they'll hand an attacker your table names, column names, and query structure if you let them through.</p>
<p>Set up the Express application. Both files define <code>authMiddleware</code> locally — yes, it's duplicated. In production, I'd pull this into a shared middleware file. For this tutorial, keeping it local means you can read either file without bouncing between three others:</p>
<pre><code class="language-javascript">// app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const { getFromCache } = require('./src/config/secrets');
const usersRouter = require('./src/routes/users');

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

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }
  const token = authHeader.split(' ')[1];
  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Health check — no auth required
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

// Status endpoint — proves Key Vault integration without needing a database
app.get('/api/status', authMiddleware, (req, res) =&gt; {
  res.json({
    message: 'All secrets loaded from Azure Key Vault',
    vault: process.env.KEY_VAULT_NAME,
    secrets_loaded: ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'],
    authenticated_as: req.user.email,
    timestamp: new Date().toISOString()
  });
});

app.use('/api/users', usersRouter);

app.use((req, res) =&gt; res.status(404).json({ error: 'Route not found' }));
app.use((err, req, res, next) =&gt; {
  console.error('Unhandled error:', err.message);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<p>The entry point loads secrets before starting the server. The server doesn't start unless all secrets load successfully:</p>
<pre><code class="language-javascript">// server.js
require('dotenv').config();
const app = require('./app');
const { loadAllSecrets } = require('./src/config/secrets');

const PORT = process.env.PORT || 3000;

async function start() {
  try {
    await loadAllSecrets();
    app.listen(PORT, () =&gt; {
      console.log(`Server running on port ${PORT}`);
    });
  } catch (err) {
    console.error('Failed to start server:', err.message);
    console.error('Hint: Run "az login" for local development, or check Managed Identity for Azure deployments.');
    process.exit(1);
  }
}

start();
</code></pre>
<p>That <code>process.exit(1)</code> is deliberate. I'd rather the app crash loudly at startup than limp along with missing credentials and fail on the first real request two hours later.</p>
<h2 id="heading-test-locally">Test Locally</h2>
<p>Create a <code>.env</code> file for local development. This only contains the Key Vault name, nothing sensitive:</p>
<pre><code class="language-bash"># .env
KEY_VAULT_NAME=your-vault-name
PORT=3000
</code></pre>
<p>Add <code>.env</code> and the deployment zip to <code>.gitignore</code>:</p>
<pre><code class="language-bash">echo ".env" &gt;&gt; .gitignore
echo "app.zip" &gt;&gt; .gitignore
</code></pre>
<p>Make sure you're logged into Azure CLI:</p>
<pre><code class="language-bash">az login
</code></pre>
<p>Start the application:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Loading secrets from Azure Key Vault...
  ✓ JWT-SECRET loaded
  ✓ DB-PASSWORD loaded
  ✓ DB-HOST loaded
All secrets loaded successfully.
Server running on port 3000
</code></pre>
<p>The order secrets load may vary — <code>Promise.all</code> fetches them in parallel and resolves as each one completes. What matters is that all three are confirmed before the server starts.</p>
<p>Test the health endpoint:</p>
<pre><code class="language-bash">curl http://localhost:3000/health
# {"status":"healthy","timestamp":"2026-07-14T19:38:11.659Z"}
</code></pre>
<p>Now prove the integration end-to-end. Grab the value you stored as <code>JWT-SECRET</code> and use it to sign a test token — paste it in for <code>YOUR-JWT-SECRET-VALUE</code>. Then hit <code>/api/status</code> with it:</p>
<pre><code class="language-bash">node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/status
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/status" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">{
  "message": "All secrets loaded from Azure Key Vault",
  "vault": "your-vault-name",
  "secrets_loaded": ["DB-HOST", "DB-PASSWORD", "JWT-SECRET"],
  "authenticated_as": "test@test.com",
  "timestamp": "2026-07-14T19:50:08.687Z"
}
</code></pre>
<p>If you got that response, the whole chain worked. The JWT was signed and verified using a secret that lived only in Key Vault — not in your code, not in your<code>.env</code>, not anywhere in the repo. Your <code>az login</code> session handled the auth locally. In production, Managed Identity takes over. Same code, nothing changes.</p>
<h3 id="heading-test-the-full-database-flow-with-docker">Test the Full Database Flow with Docker</h3>
<p>The app reads <code>DB-HOST</code> and <code>DB-PASSWORD</code> from Key Vault, so those secrets need to match your local Docker container. Update them now:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "localhost"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "demopassword123"
</code></pre>
<p>Docker up a Postgres container. The password has to match <code>demopassword123</code> — that's what you just put in Key Vault:</p>
<pre><code class="language-bash">docker run --name pg-demo \
  -e POSTGRES_USER=dbadmin \
  -e POSTGRES_PASSWORD=demopassword123 \
  -e POSTGRES_DB=myapp \
  -p 5432:5432 \
  -d postgres:15
</code></pre>
<p>Get the table created and throw in some test rows:</p>
<pre><code class="language-bash">docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW());"

docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "INSERT INTO users (email) VALUES ('alice@example.com'), ('bob@example.com'), ('carol@example.com');"
</code></pre>
<p>Kill the server and bring it back up — secrets load at startup, so it needs a fresh run to pick up what you just changed in Key Vault:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>Call the users endpoint with a valid JWT:</p>
<pre><code class="language-bash"># Generate a token (use the same value you stored as JWT-SECRET in Key Vault)
node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/users
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/users" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">[
  { "id": 1, "email": "alice@example.com", "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 2, "email": "bob@example.com",   "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 3, "email": "carol@example.com", "created_at": "2026-07-14T19:59:21.064Z" }
]
</code></pre>
<p>That query ran using a password that came straight from Key Vault. It's not in your <code>.env</code>, not hardcoded anywhere, and not in a local variable. The repo has nothing worth stealing.</p>
<p>Before you deploy, put the real production values back in Key Vault:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "your-db-host.postgres.database.azure.com"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "your-super-secret-password"
</code></pre>
<p>If you skip this, the deployed app will try to connect to <code>localhost</code> and fail immediately — <code>localhost</code> doesn't exist on App Service.</p>
<h2 id="heading-deploy-to-azure-app-service">Deploy to Azure App Service</h2>
<p><strong>Note:</strong> This section creates the App Service infrastructure. The actual code deployment (zip upload) happens at the end of the next section — the app must have Key Vault access configured before its first startup, or it will fail immediately and exit.</p>
<p>Create the App Service:</p>
<pre><code class="language-bash"># Create an App Service Plan (B1 is the cheapest paid tier)
az appservice plan create \
  --name keyvault-demo-plan \
  --resource-group keyvault-demo-rg \
  --sku B1 \
  --is-linux

# Create the Web App
az webapp create \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --plan keyvault-demo-plan \
  --runtime "NODE:18-lts"

# Set app settings — KEY_VAULT_NAME tells the app which vault to use
# NODE_ENV=production enables SSL for the database connection
az webapp config appsettings set \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --settings KEY_VAULT_NAME=your-vault-name NODE_ENV=production
</code></pre>
<h2 id="heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</h2>
<p>Enable Managed Identity on the app. This gives it an identity in Microsoft Entra ID that Key Vault can trust:</p>
<pre><code class="language-bash"># Enable system-assigned managed identity
az webapp identity assign \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>The following commands capture the <code>principalId</code> automatically and use it to grant the role:</p>
<pre><code class="language-bash"># Get the principal ID
PRINCIPAL_ID=$(az webapp identity show \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --query principalId \
  --output tsv)

# Get the Key Vault resource ID
KV_ID=$(az keyvault show \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --query id \
  --output tsv)

# Grant the app the "Key Vault Secrets User" role
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id $PRINCIPAL_ID \
  --scope $KV_ID
</code></pre>
<p>The <code>Key Vault Secrets User</code> role allows the app to read secrets. It can't create, update, or delete them. This is the principle of least privilege — the application can only do what it needs to do.</p>
<p>Time to ship it. Linux/macOS can run this directly — Windows users, open Git Bash (it ships with Git for Windows):</p>
<pre><code class="language-bash">zip -r app.zip . -x "node_modules/*" ".git/*" ".env" "app.zip"
</code></pre>
<p>Then deploy:</p>
<pre><code class="language-bash">az webapp deployment source config-zip \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --src app.zip
</code></pre>
<p>The deployed application authenticates to Key Vault using its Managed Identity automatically. No passwords, no client secrets, no credentials of any kind in the deployment.</p>
<p>Check the health endpoint to confirm it's running:</p>
<pre><code class="language-bash">curl https://my-keyvault-node-app.azurewebsites.net/health
# {"status":"healthy","timestamp":"..."}
</code></pre>
<p>If it won't start, pull the logs:</p>
<pre><code class="language-bash">az webapp log tail --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<p>Nine times out of ten, it's that the Key Vault role assignment has not been propagated yet. Give it 2–3 minutes, then restart:</p>
<pre><code class="language-bash">az webapp restart --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<h2 id="heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</h2>
<p>One of the biggest practical benefits of Key Vault is secret rotation. When a database password needs to change, you update it in Key Vault — not in your app:</p>
<pre><code class="language-bash">az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "new-rotated-password"
</code></pre>
<p>The cache builds at startup, so you don't need a redeploy — a restart is enough:</p>
<pre><code class="language-bash">az webapp restart \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>No code change. No new deployment. The secret is rotated, and the app is using the new value in seconds.</p>
<p>If you need zero-downtime rotation, add a <code>/refresh-secrets</code> endpoint behind admin auth that clears the cache and then calls <code>loadAllSecrets()</code>. The order matters — <code>loadAllSecrets()</code> uses <code>getSecret()</code> which returns cached values if they exist, so you must clear the cache first, or it will reload nothing. This is optional but useful for long-running processes that can't afford a restart.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p><code>CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token</code></p>
<p>You're not logged into Azure CLI. Run <code>az login</code> and try again. On Azure App Service, check that Managed Identity is enabled and the role assignment was created correctly.</p>
<p><code>RestError: Forbidden — The user does not have secrets get permission</code></p>
<p>The Managed Identity isn't wired up to Key Vault yet. Go back and run the <code>az role assignment create</code> command. If you already did, it might just need time. Azure can take 2–3 minutes to propagate role assignments, so give it a moment before you dig further.</p>
<p><code>Error: Secret "DB-PASSWORD" not loaded. Did loadAllSecrets() run?</code></p>
<p><code>getFromCache()</code> ran before <code>loadAllSecrets()</code> finished, meaning the startup sequence is out of order. Open <code>server.js</code> and confirm <code>await loadAllSecrets()</code> comes before <code>app.listen()</code>. If the order's fine, the secret might just not be in the vault yet. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> to double-check. (A name mismatch — wrong case, typo — throws <code>SecretNotFound</code> instead, which is the entry below.)</p>
<p><strong>App starts locally but fails on Azure App Service</strong></p>
<p>Almost always, the app setting. Either <code>KEY_VAULT_NAME</code> isn't in App Service configuration at all, or the vault name has a typo. Run <code>az webapp log tail</code> to see the actual startup error — that'll tell you which one.</p>
<p><code>AuthorizationFailed</code> <strong>when running</strong> <code>az role assignment create</code></p>
<p>You are a guest user in your Azure tenant and lack the Owner role needed to assign roles. Switch the existing vault to the access policy model — no need to recreate it or lose your secrets:</p>
<pre><code class="language-bash">az keyvault update \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --enable-rbac-authorization false
</code></pre>
<p>If this happened during <strong>Set Up the Key Vault</strong> (granting yourself access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $(az ad signed-in-user show --query id -o tsv) \
  --secret-permissions get set list delete
</code></pre>
<p>If this happened during <strong>Grant Key Vault Access to the App</strong> (granting the Managed Identity access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $PRINCIPAL_ID \
  --secret-permissions get list
</code></pre>
<p><strong>Key Vault returns</strong> <code>SecretNotFound</code></p>
<p>The secret was never added, was deleted, or its name doesn't match exactly what your code requests — Key Vault secret names are case-sensitive. A secret named <code>db-password</code> and a request for <code>DB-PASSWORD</code> are different names. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> and compare what's actually in the vault against what <code>loadAllSecrets()</code> is asking for in <code>src/config/secrets.js</code>. Usually, it's a casing issue or a stray hyphen.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The <code>.env</code> file in this project contains exactly one value: the Key Vault name. That's not sensitive. Every actual secret — database passwords, API keys, signing secrets — lives in Key Vault and never touches your codebase or your deployment pipeline.</p>
<p>This is the pattern I use on Azure projects now. The startup check is the part I find most useful in practice: if Key Vault is unreachable or a secret is missing, the server exits immediately with a clear error instead of starting up broken and failing on the first real request. You find out right away, rather than getting an obscure database connection error two hours later.</p>
<p>To add another secret, put it in Key Vault and drop its name into the <code>secretNames</code> array — that's it. Everything else scales with it.</p>
<p>The full working code is on GitHub: <a href="https://github.com/ziaongit/nodejs-azure-keyvault">nodejs-azure-keyvault</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Fix Common Web Application Security Vulnerabilities in Node.js ]]>
                </title>
                <description>
                    <![CDATA[ Here's something that tends to surprise developers who are new to security: most web vulnerabilities aren't the result of sophisticated attacks. They come from code patterns that look completely reaso ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-fix-common-web-application-security-vulnerabilities-in-node-js/</link>
                <guid isPermaLink="false">6a581c65dba34eeb664be8e2</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ owasp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hackita ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jul 2026 23:48:53 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/b2ec81b6-d6eb-41f0-9fa5-7570914ef97d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Here's something that tends to surprise developers who are new to security: most web vulnerabilities aren't the result of sophisticated attacks. They come from code patterns that look completely reasonable: trusting a value from the URL, applying a request body to a database update, or running two queries where one should've been enough.</p>
<p>This guide covers six of those patterns. For each one, you'll see a real code example that creates the vulnerability, an explanation of what makes it dangerous, and a corrected version with notes on exactly what changed and why.</p>
<p>No security background is required to follow along here. It'll just help to have some familiarity with Node.js and SQL.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>The examples assume you're comfortable with:</p>
<ul>
<li><p>Node.js and Express.js basics</p>
</li>
<li><p>SQL queries</p>
</li>
<li><p>How HTTP requests and responses work</p>
</li>
<li><p>Basic authentication concepts (sessions, tokens)</p>
</li>
</ul>
<p><strong>Note on code examples</strong>: Throughout this tutorial, <code>db.query()</code> is a fictional database helper that returns a single row object for <code>SELECT</code> queries (or <code>null</code> if not found), and a result object for <code>INSERT</code>/<code>UPDATE</code> queries. The <code>connection.query()</code> in the race conditions section uses the <a href="https://github.com/sidorares/node-mysql2">mysql2</a> promise API directly, where <code>query()</code> returns <code>[rows, fields]</code>. Adapt the syntax to the database driver you use.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-broken-access-control-and-idor">1. Broken Access Control and IDOR</a></p>
</li>
<li><p><a href="#heading-2-mass-assignment">2. Mass Assignment</a></p>
</li>
<li><p><a href="#heading-3-prototype-pollution">3. Prototype Pollution</a></p>
</li>
<li><p><a href="#heading-4-race-conditions">4. Race Conditions</a></p>
</li>
<li><p><a href="#heading-5-business-logic-flaws">5. Business Logic Flaws</a></p>
</li>
<li><p><a href="#heading-6-jwt-misconfiguration">6. JWT Misconfiguration</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-1-broken-access-control-and-idor">1. Broken Access Control and IDOR</h2>
<p><a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/">Broken Access Control</a> has topped the OWASP Top 10 since 2021, and it's not hard to see why. The most common form is <strong>Insecure Direct Object Reference (IDOR)</strong>: the application exposes a database ID in a URL, a user changes the number, and suddenly they're looking at someone else's data.</p>
<p>The fix seems obvious in hindsight. But it keeps appearing in production code because authentication and authorization get conflated. Confirming that a user is logged in is not the same as confirming they're allowed to access a specific resource.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical user profile endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/api/users/:id/profile', authenticate, async (req, res) =&gt; {
  const userId = req.params.id;

  const user = await db.query(
    'SELECT id, name, email, address FROM users WHERE id = ?',
    [userId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>The <code>authenticate</code> middleware confirms that the request includes a valid token. But it doesn't confirm whether the authenticated user is allowed to access the requested resource.</p>
<p>Any authenticated user can request <code>/api/users/1/profile</code>, <code>/api/users/2/profile</code>, and so on and retrieve other users' data.</p>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>Authentication confirms <em>who</em> you are. Authorization confirms <em>what you're allowed to do</em>. The code above does the first and skips the second entirely.</p>
<p>With a sequential numeric ID, a curious user doesn't need any special tools. They can just change <code>1</code> to <code>2</code> in the URL. But the same problem exists with UUIDs or slugs if the ownership check is missing.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Verify server-side that the authenticated user owns — or is explicitly authorized to access — the requested resource:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/api/users/:id/profile', authenticate, async (req, res) =&gt; {
  // Reject anything that isn't a string of digits — parseInt("12abc") would return 12
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const requestedId = Number(req.params.id);

  if (requestedId &lt; 1) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  // The authenticated user's ID is set by the authenticate middleware
  const authenticatedId = req.user.id;

  // Enforce ownership: users can only access their own profile
  if (requestedId !== authenticatedId) {
    return res.status(403).json({ error: 'Forbidden' });
  }

  const user = await db.query(
    'SELECT id, name, email, address FROM users WHERE id = ?',
    [requestedId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>For admin endpoints that legitimately need to access any user, enforce role-based authorization explicitly:</p>
<pre><code class="language-javascript">// Admin endpoint with explicit role check
app.get('/api/admin/users/:id', authenticate, requireRole('admin'), async (req, res) =&gt; {
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const userId = Number(req.params.id);

  const user = await db.query(
    'SELECT id, name, email, role FROM users WHERE id = ?',
    [userId]
  );

  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json(user);
});
</code></pre>
<p>Here's what changed and why:</p>
<ul>
<li><p><code>/^\d+$/.test(req.params.id)</code> rejects anything that isn't a pure string of digits. <code>parseInt("12abc", 10)</code> would silently return <code>12</code> and pass further checks. The regex prevents this.</p>
</li>
<li><p><code>Number(req.params.id)</code> converts the already-validated string to a number safely.</p>
</li>
<li><p>The comparison <code>requestedId !== authenticatedId</code> enforces ownership.</p>
</li>
<li><p>Admin functionality is a separate endpoint with its own authorization check.</p>
</li>
</ul>
<p><strong>Never infer authorization from a URL parameter. Derive it from the authenticated session.</strong></p>
<h2 id="heading-2-mass-assignment">2. Mass Assignment</h2>
<p>Mass assignment is one of those vulnerabilities that's almost invisible when you write it. You're just being efficient, right? Why iterate over fields manually when you can pass the whole object?</p>
<p>The problem is that your database table knows about fields your users were never supposed to touch.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a user profile update endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.put('/api/users/me', authenticate, async (req, res) =&gt; {
  const userId = req.user.id;

  // req.body contains everything the client sends
  const updates = req.body;

  await db.query(
    'UPDATE users SET ? WHERE id = ?',
    [updates, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p>The <code>users</code> table has these columns:</p>
<pre><code class="language-sql">CREATE TABLE users (
  id           INT PRIMARY KEY,
  name         VARCHAR(100),
  email        VARCHAR(100),
  bio          TEXT,
  role         ENUM('user', 'moderator', 'admin') DEFAULT 'user',
  credits      INT DEFAULT 0,
  is_banned    BOOLEAN DEFAULT false
);
</code></pre>
<p>The developer intended users to update <code>name</code>, <code>email</code>, and <code>bio</code>. But <code>role</code>, <code>credits</code>, and <code>is_banned</code> are also in the table — and the query updates whatever fields the client sends.</p>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>That request body goes straight into the SQL query. The <code>users</code> table also has <code>role</code>, <code>credits</code>, and <code>is_banned</code> — and the query doesn't know or care which fields the developer "intended" to expose.</p>
<p>A user who sends this:</p>
<pre><code class="language-json">{
  "name": "Alice",
  "role": "admin",
  "credits": 100000,
  "is_banned": false
}
</code></pre>
<p>Has just promoted themselves to admin, cleared their ban, and given themselves a hundred thousand credits.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Build the update object yourself, field by field, using an explicit allowlist:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.put('/api/users/me', authenticate, async (req, res) =&gt; {
  const userId = req.user.id;

  // Only these fields may be updated by the user
  const ALLOWED_FIELDS = ['name', 'email', 'bio'];
  const updates = {};

  for (const field of ALLOWED_FIELDS) {
    if (req.body[field] !== undefined) {
      updates[field] = req.body[field];
    }
  }

  if (Object.keys(updates).length === 0) {
    return res.status(400).json({ error: 'No valid fields provided' });
  }

  // Validate individual fields
  // isValidEmail is a simple helper: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
  if (updates.email &amp;&amp; !isValidEmail(updates.email)) {
    return res.status(400).json({ error: 'Invalid email format' });
  }

  if (updates.name &amp;&amp; (typeof updates.name !== 'string' || updates.name.length &gt; 100)) {
    return res.status(400).json({ error: 'Name must be a string of 100 characters or fewer' });
  }

  await db.query(
    'UPDATE users SET ? WHERE id = ?',
    [updates, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p>For admin operations that legitimately update sensitive fields, use a separate endpoint with its own authorization:</p>
<pre><code class="language-javascript">// Admin-only endpoint for changing user roles
app.put('/api/admin/users/:id/role', authenticate, requireRole('admin'), async (req, res) =&gt; {
  if (!/^\d+$/.test(req.params.id)) {
    return res.status(400).json({ error: 'Invalid user ID' });
  }

  const userId = Number(req.params.id);
  const { role } = req.body;

  const VALID_ROLES = ['user', 'moderator', 'admin'];

  if (!VALID_ROLES.includes(role)) {
    return res.status(400).json({ error: 'Invalid role' });
  }

  await db.query(
    'UPDATE users SET role = ? WHERE id = ?',
    [role, userId]
  );

  res.json({ success: true });
});
</code></pre>
<p><strong>Don't spread</strong> <code>req.body</code> <strong>into a database query. Build the update object field by field.</strong></p>
<h2 id="heading-3-prototype-pollution">3. Prototype Pollution</h2>
<p>Prototype pollution occurs when untrusted data is merged into an object recursively, allowing an attacker to inject properties into <code>Object.prototype</code> (the base object that every plain JavaScript object inherits from).</p>
<p>The OWASP Top 10 covers this under <a href="https://owasp.org/Top10/A08_2021-Software_and_Data_Integrity_Failures/">Software and Data Integrity Failures (A08:2021)</a>.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>A configuration merge utility that processes user-supplied settings:</p>
<pre><code class="language-javascript">// Vulnerable recursive merge function
function mergeConfig(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' &amp;&amp; source[key] !== null) {
      if (!target[key]) target[key] = {};
      mergeConfig(target[key], source[key]); // recursive call
    } else {
      target[key] = source[key]; // triggers __proto__ setter via bracket notation
    }
  }
}

app.post('/api/settings', authenticate, (req, res) =&gt; {
  const userSettings = {};
  mergeConfig(userSettings, req.body); // merge untrusted input
  applySettings(userSettings);
  res.json({ success: true });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>An attacker sends this request body:</p>
<pre><code class="language-json">{
  "__proto__": {
    "isAdmin": true
  }
}
</code></pre>
<p>The recursive <code>mergeConfig</code> function reaches the <code>__proto__</code> key and executes <code>target['__proto__']['isAdmin'] = true</code>. Because <code>__proto__</code> is JavaScript's prototype accessor, this writes directly to <code>Object.prototype</code>. After the merge:</p>
<pre><code class="language-javascript">const anyObject = {};
console.log(anyObject.isAdmin); // true — inherited from Object.prototype
</code></pre>
<p>Every plain object in the running application now inherits <code>isAdmin: true</code>. If any authorization check looks like this:</p>
<pre><code class="language-javascript">if (user.isAdmin) { /* grant admin access */ }
</code></pre>
<p>That check now passes for every user, regardless of their actual role.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Store user state server-side and validate each field individually. Never recursively merge untrusted input:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/settings', authenticate, async (req, res) =&gt; {
  // Load current settings from the database — never from the client
  const current = await db.query(
    'SELECT theme, language, notifications FROM user_settings WHERE user_id = ?',
    [req.user.id]
  );

  // Validate each field against an explicit allowlist
  const safeSettings = {
    theme: validateEnum(req.body.theme, ['light', 'dark'], current.theme),
    language: validateEnum(req.body.language, ['en', 'es', 'fr', 'de'], current.language),
    notifications: typeof req.body.notifications === 'boolean'
      ? req.body.notifications
      : current.notifications
  };

  await db.query(
    'UPDATE user_settings SET ? WHERE user_id = ?',
    [safeSettings, req.user.id]
  );

  res.json({ success: true });
});

function validateEnum(value, allowed, defaultValue) {
  return allowed.includes(value) ? value : defaultValue;
}
</code></pre>
<p>If you need a merge utility, use <code>Object.create(null)</code> as the base — it has no prototype, so <code>__proto__</code> can't be polluted — and allowlist keys explicitly:</p>
<pre><code class="language-javascript">// Safe merge: base object with no prototype
function safeMerge(allowedKeys, source) {
  const result = Object.create(null); // no prototype = no pollution possible

  for (const key of allowedKeys) {
    if (key in source &amp;&amp; typeof source[key] !== 'object') {
      result[key] = source[key];
    }
  }

  return result;
}
</code></pre>
<p><strong>Rules</strong>:</p>
<ul>
<li><p>Never recursively merge untrusted input into a plain object.</p>
</li>
<li><p>Store application state server-side. Don't trust clients to carry it.</p>
</li>
<li><p>Use <code>Object.create(null)</code> for data containers that will hold untrusted keys.</p>
</li>
<li><p>Validate each field by type and allowed values before using it.</p>
</li>
</ul>
<h2 id="heading-4-race-conditions">4. Race Conditions</h2>
<p>Race conditions are tricky because the code is perfectly correct. It's the timing that breaks it. Two requests arrive at almost the same moment, both check the same condition, both see a valid result, and both proceed. The result is something that should only happen once, happening twice.</p>
<p>This is called a <strong>Time-of-Check to Time-of-Use (TOCTOU)</strong> problem: the state you checked is no longer the state you're acting on.</p>
<h3 id="heading-how-to-identify-this-pattern-in-your-code">How to Identify This Pattern in Your Code</h3>
<p>A single-use coupon redemption endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.post('/api/redeem-coupon', authenticate, async (req, res) =&gt; {
  const { couponCode } = req.body;
  const userId = req.user.id;

  // Step 1: Check if the coupon is still valid
  const coupon = await db.query(
    'SELECT id, discount_amount, used FROM coupons WHERE code = ? AND used = false',
    [couponCode]
  );

  if (!coupon) {
    return res.status(400).json({ error: 'Invalid or already used coupon' });
  }

  // Time gap: another request can pass Step 1 here before Step 3 runs

  // Step 2: Apply the discount
  await applyDiscountToOrder(userId, coupon.discount_amount);

  // Step 3: Mark coupon as used
  await db.query(
    'UPDATE coupons SET used = true, used_by = ? WHERE code = ?',
    [userId, couponCode]
  );

  res.json({ success: true });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>Two requests arrive with the same coupon code at nearly the same time. Both hit Step 1 before either reaches Step 3. Both read <code>used = false</code>. Both apply the discount. One coupon, used twice.</p>
<p>The same window exists anywhere you read-then-write: balance checks before deductions, inventory checks before reservations, vote checks before incrementing. Any of them can be exploited the same way.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Replace the check-then-act pattern with an atomic operation. An atomic database update guarantees that the condition check and the write happen as a single indivisible unit:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/redeem-coupon', authenticate, async (req, res) =&gt; {
  const { couponCode } = req.body;
  const userId = req.user.id;

  const connection = await db.getConnection();

  try {
    await connection.beginTransaction();

    // Atomic: only one request can update a row where used = false.
    // The database row lock ensures only one request succeeds.
    const [result] = await connection.query(
      `UPDATE coupons
       SET used = true, used_by = ?, used_at = NOW()
       WHERE code = ? AND used = false`,
      [userId, couponCode]
    );

    if (result.affectedRows === 0) {
      await connection.rollback();
      return res.status(400).json({ error: 'Invalid or already used coupon' });
    }

    const [couponRows] = await connection.query(
      'SELECT discount_amount FROM coupons WHERE code = ?',
      [couponCode]
    );
    const coupon = couponRows[0];

    await applyDiscountToOrder(userId, coupon.discount_amount, connection);

    await connection.commit();

    res.json({ success: true, discount: coupon.discount_amount });

  } catch (error) {
    await connection.rollback();
    console.error('Coupon redemption failed:', error);
    res.status(500).json({ error: 'Could not process the coupon' });
  } finally {
    connection.release();
  }
});
</code></pre>
<p>The key change is <code>UPDATE ... WHERE code = ? AND used = false</code>. The database acquires a row lock during the update. Only one concurrent request can succeed. The second request finds <code>affectedRows = 0</code> and returns an error — correctly.</p>
<p><strong>Any time you read a value to make a decision before writing — that's a potential race condition. Make the check and the write atomic.</strong></p>
<h2 id="heading-5-business-logic-flaws">5. Business Logic Flaws</h2>
<p>Business logic flaws are the hardest category to catch. Automated scanners won't find them, and code review might miss them too, because the code works exactly as written. The problem is in what the code was designed to do, not how it does it.</p>
<p>The most common form: trusting the client to send sensible numeric values.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>An e-commerce checkout endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.post('/api/checkout', authenticate, async (req, res) =&gt; {
  const { items } = req.body;

  let total = 0;
  const processedItems = [];

  for (const item of items) {
    const product = await db.query(
      'SELECT id, price FROM products WHERE id = ?',
      [item.productId]
    );

    if (!product) {
      return res.status(400).json({ error: `Product not found: ${item.productId}` });
    }

    // Trust the quantity value from the client
    const itemTotal = product.price * item.quantity;
    total += itemTotal;

    processedItems.push({ productId: product.id, quantity: item.quantity, price: product.price });
  }

  if (total &gt; 100) {
    total = total * 0.9; // 10% discount
  }

  await createOrder(req.user.id, processedItems, total);
  res.json({ success: true, total });
});
</code></pre>
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p><strong>Problem 1 — Negative quantity</strong>: The code multiplies the server's price by the client's quantity without checking that the quantity is positive. A user sends <code>"quantity": -5</code> on an expensive item. Its contribution to the total becomes negative, reducing the overall total.</p>
<p><strong>Problem 2 — Floating point arithmetic</strong>: <code>0.1 + 0.2</code> in JavaScript is <code>0.30000000000000004</code>. Without rounding, financial calculations accumulate errors over time.</p>
<p><strong>Problem 3 — Discount manipulation</strong>: If a separate endpoint allows modifying an order after checkout without recalculating the total, a user could add items to earn the discount, then remove items while keeping the discounted price.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Validate every numeric input and recalculate all totals server-side. Use integer arithmetic (cents) for money to avoid floating point errors:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.post('/api/checkout', authenticate, async (req, res) =&gt; {
  const { items } = req.body;

  if (!Array.isArray(items) || items.length === 0) {
    return res.status(400).json({ error: 'Cart must contain at least one item' });
  }

  if (items.length &gt; 50) {
    return res.status(400).json({ error: 'Cart cannot contain more than 50 items' });
  }

  let totalCents = 0; // Integer arithmetic avoids floating point errors
  const processedItems = [];

  for (const item of items) {
    if (!/^\d+$/.test(String(item.productId))) {
      return res.status(400).json({ error: `Invalid product ID: ${item.productId}` });
    }

    const productId = Number(item.productId);

    // Validate quantity: must be a string of digits only, between 1 and 10
    // parseInt("3abc", 10) returns 3 — we use regex to prevent this
    if (!/^\d+$/.test(String(item.quantity))) {
      return res.status(400).json({
        error: `Invalid quantity for product ${productId}`
      });
    }

    const quantity = Number(item.quantity);

    if (quantity &lt; 1 || quantity &gt; 10) {
      return res.status(400).json({
        error: `Quantity for product ${productId} must be between 1 and 10`
      });
    }

    const product = await db.query(
      'SELECT id, name, price_cents, stock FROM products WHERE id = ? AND active = true',
      [productId]
    );

    if (!product) {
      return res.status(400).json({ error: `Product not found: ${productId}` });
    }

    if (product.stock &lt; quantity) {
      return res.status(400).json({
        error: `Insufficient stock for "${product.name}"`
      });
    }

    // Use the server's price — never trust a price from the client
    totalCents += product.price_cents * quantity;

    processedItems.push({
      productId: product.id,
      name: product.name,
      quantity,
      unitPriceCents: product.price_cents
    });
  }

  // Integer arithmetic for the discount calculation
  const discountMultiplier = totalCents &gt; 10000 ? 90 : 100; // 10000 cents = $100
  const finalTotalCents = Math.round(totalCents * discountMultiplier / 100);

  await createOrder(req.user.id, processedItems, finalTotalCents);

  res.json({
    success: true,
    total: (finalTotalCents / 100).toFixed(2),
    currency: 'USD'
  });
});
</code></pre>
<p>What changed and why:</p>
<ul>
<li><p>Integer (cents) arithmetic eliminates floating point errors. <code>10000 + 3000</code> in integer cents is always exact.</p>
</li>
<li><p><code>quantity &lt; 1 || quantity &gt; 10</code> prevents negative quantities and unreasonably large orders.</p>
</li>
<li><p><code>items.length &gt; 50</code> prevents oversized requests.</p>
</li>
<li><p><code>product.stock &lt; quantity</code> ensures the order doesn't exceed available inventory.</p>
</li>
<li><p>All totals, discounts, and final prices are calculated server-side from server-side prices.</p>
</li>
</ul>
<p><strong>Define the valid range for every numeric input. Recalculate every total server-side. The client isn't a trusted source for prices or quantities.</strong></p>
<h2 id="heading-6-jwt-misconfiguration">6. JWT Misconfiguration</h2>
<p>JWTs are everywhere in Node.js APIs, and the <code>jsonwebtoken</code> library makes them easy to use. That's both good and bad: they're easy to use correctly, but they're also easy to use in ways that look fine until they're not.</p>
<p>The OWASP Top 10 classifies authentication failures under <a href="https://owasp.org/Top10/A07_2021-Identification_and_Authentication_Failures/">Identification and Authentication Failures (A07:2021)</a>. Three misconfigurations show up repeatedly.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p><strong>Mistake 1 — Algorithm not specified in</strong> <code>verify()</code>:</p>
<pre><code class="language-javascript">// Vulnerable
const decoded = jwt.verify(token, process.env.JWT_SECRET);
</code></pre>
<p>Without an <code>algorithms</code> option, some JWT implementations can be tricked into accepting tokens that declare <code>"alg": "none"</code> in their header — meaning no signature is required at all.</p>
<p><strong>Mistake 2 — Weak or hardcoded secrets</strong>:</p>
<pre><code class="language-javascript">// Vulnerable
const token = jwt.sign({ userId: user.id }, 'secret');
</code></pre>
<p>A short or predictable HS256 secret can be brute-forced offline if an attacker obtains a valid token.</p>
<p><strong>Mistake 3 — Sensitive data in the payload</strong>:</p>
<pre><code class="language-javascript">// Vulnerable
const token = jwt.sign({
  userId: user.id,
  passwordHash: user.passwordHash, // never do this
  role: user.role
}, secret);
</code></pre>
<p>JWT payloads are base64-encoded, not encrypted. Anyone who holds the token can decode the payload and read its contents.</p>
<h3 id="heading-how-to-fix-these-issues">How to Fix These Issues</h3>
<pre><code class="language-javascript">// Secure JWT implementation
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// Generate a strong secret once and store it as an environment variable:
// node -e "console.log(crypto.randomBytes(64).toString('hex'))"
const JWT_SECRET = process.env.JWT_SECRET;

if (!JWT_SECRET || Buffer.from(JWT_SECRET, 'hex').length &lt; 32) {
  throw new Error('JWT_SECRET must be at least 32 random bytes');
}

function signToken(userId) {
  return jwt.sign(
    { sub: userId },         // 'sub' is the standard claim for the user identifier
    JWT_SECRET,
    {
      algorithm: 'HS256',    // always declare the algorithm explicitly
      expiresIn: '15m',      // short-lived tokens reduce the window if a token is stolen
      issuer: 'your-app-name',
      audience: 'your-app-name'
    }
  );
}

function verifyToken(token) {
  return jwt.verify(token, JWT_SECRET, {
    algorithms: ['HS256'],   // whitelist only the expected algorithm
    issuer: 'your-app-name',
    audience: 'your-app-name'
  });
}

function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];

  try {
    const decoded = verifyToken(token);
    req.user = { id: decoded.sub };
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}
</code></pre>
<p><strong>A note on token revocation</strong>: JWTs are stateless, meaning your server keeps no record of them. This means you can't immediately invalidate a token on logout or account compromise without extra infrastructure.</p>
<p>Common solutions include short-lived access tokens (15 minutes) paired with refresh tokens stored server-side, or a token denylist in a fast store like Redis. Choose the approach that matches your application's requirements.</p>
<p><strong>JWT security checklist</strong>:</p>
<ul>
<li><p>Always pass <code>algorithms: ['HS256']</code> to <code>verify()</code>.</p>
</li>
<li><p>Use a secret of at least 32 random bytes generated with <code>crypto.randomBytes</code>.</p>
</li>
<li><p>Set short expiration times.</p>
</li>
<li><p>Store only non-sensitive identifiers (user ID) in the payload — not emails, passwords, or roles.</p>
</li>
<li><p>Use standard claims: <code>sub</code>, <code>iss</code>, <code>aud</code>, <code>exp</code>.</p>
</li>
<li><p>Plan your revocation strategy before going to production.</p>
</li>
</ul>
<h2 id="heading-summary">Summary</h2>
<p>Here's a quick reference for the six vulnerability categories covered in this tutorial:</p>
<table>
<thead>
<tr>
<th>Vulnerability</th>
<th>Root Cause</th>
<th>Core Fix</th>
</tr>
</thead>
<tbody><tr>
<td>IDOR</td>
<td>Authorization check missing</td>
<td>Verify ownership from the authenticated session</td>
</tr>
<tr>
<td>Mass Assignment</td>
<td>All request body fields applied to database</td>
<td>Allowlist allowed fields explicitly</td>
</tr>
<tr>
<td>Prototype Pollution</td>
<td>Recursive merge of untrusted input</td>
<td>Store state server-side. Use <code>Object.create(null)</code> for merge targets.</td>
</tr>
<tr>
<td>Race Condition</td>
<td>Check-then-act without atomicity</td>
<td>Atomic <code>UPDATE ... WHERE condition</code> or transactions</td>
</tr>
<tr>
<td>Business Logic Flaw</td>
<td>Trusting client-supplied numeric values</td>
<td>Regex-validate inputs. Use integer arithmetic for money.</td>
</tr>
<tr>
<td>JWT Misconfiguration</td>
<td>No algorithm allowlist, weak secrets</td>
<td>Explicit algorithm array, strong random secret, short expiration</td>
</tr>
</tbody></table>
<p>None of these vulnerabilities requires a clever attacker. They just need code that trusts the wrong thing at the wrong time.</p>
<p>The patterns worth internalizing: always derive authorization from the session, never from the request. Validate every numeric input with a range. Make financial writes atomic. Build your JWT configuration explicitly.</p>
<p>Understanding how to fix these vulnerabilities is one side of the picture. Understanding how they're actually identified and exploited during a real security assessment is the other.</p>
<p>If you want to go deeper on the offensive side — how penetration testers approach web application targets, what they look for, and how they chain multiple flaws together — this <a href="https://hackita.it/articoli/attacchi-applicazioni-web/">guide to web application attack techniques</a> covers that perspective in detail.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a RAG Chatbot for Your Docs with Node.js, Google Gemini, and pgvector ]]>
                </title>
                <description>
                    <![CDATA[ I was helping a team that had a 200-page API documentation PDF. Every new engineer spent their first two weeks Ctrl+F-ing through it, asking the same questions in Slack, getting redirected to the same ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-rag-chatbot-nodejs-gemini-pgvector/</link>
                <guid isPermaLink="false">6a57a6aa328507d0d4d46169</guid>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ PostgreSQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Wed, 15 Jul 2026 15:26:34 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9aa3d8d3-9c51-42a7-8e78-907802394ea1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I was helping a team that had a 200-page API documentation PDF. Every new engineer spent their first two weeks Ctrl+F-ing through it, asking the same questions in Slack, getting redirected to the same paragraphs on page 47.</p>
<p>The doc was accurate. It was even well-written. But nobody could find anything in it fast enough for it to be useful.</p>
<p>That's the problem RAG, or Retrieval-Augmented Generation, solves.</p>
<p>The naïve approach is to stuff your entire PDF into a prompt and let the model figure it out. That breaks down fast: context windows overflow, costs spike on every request, and the model loses the thread somewhere in the wall of text.</p>
<p>RAG takes a different approach. Your documents get broken into small chunks upfront. Ask it a question and it digs out the 3 or 4 chunks that best match it — those are what the model actually sees. The model gets a tight, focused context. The answer comes from what your document actually says — not from whatever the LLM memorized during training.</p>
<p>In this tutorial, you'll build that from scratch. Upload any PDF — an API reference, an internal spec, a research paper — and ask questions about it in plain English. The system finds the relevant sections and answers from the document itself, not from general training data.</p>
<p>The stack: Node.js with Express, Google Gemini for embeddings, Groq for text generation, and pgvector running in Docker. Every piece of it is free — no credit card, no trial period.</p>
<p>The complete code is on GitHub at <a href="https://github.com/ziaongit/nodejs-rag-chatbot">nodejs-rag-chatbot</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-rag-works">How RAG Works</a></p>
</li>
<li><p><a href="#heading-what-were-building">What We're Building</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-set-up-postgres-with-pgvector-using-docker">Set Up Postgres with pgvector Using Docker</a></p>
</li>
<li><p><a href="#heading-connect-to-the-database">Connect to the Database</a></p>
</li>
<li><p><a href="#heading-build-the-ingestion-pipeline">Build the Ingestion Pipeline</a></p>
</li>
<li><p><a href="#heading-build-the-query-pipeline">Build the Query Pipeline</a></p>
</li>
<li><p><a href="#heading-build-the-chat-api-with-express">Build the Chat API with Express</a></p>
</li>
<li><p><a href="#heading-test-the-chatbot">Test the Chatbot</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-how-to-swap-in-openai">How to Swap in OpenAI</a></p>
</li>
<li><p><a href="#heading-what-to-build-next">What to Build Next</a></p>
</li>
</ul>
<h2 id="heading-how-rag-works">How RAG Works</h2>
<p>RAG has two phases, and the code maps directly to both.</p>
<p><strong>Ingestion phase</strong> — runs once when you upload a document:</p>
<ol>
<li><p>Pull the raw text out of the PDF</p>
</li>
<li><p>Break it into chunks of 400 to 600 characters each, with a bit of overlap so nothing important gets cut at a boundary</p>
</li>
<li><p>Run each chunk through an embedding model, which turns it into a vector (a long list of numbers that captures what the text means)</p>
</li>
<li><p>Store each chunk and its vector in Postgres</p>
</li>
</ol>
<p><strong>Query phase</strong> — runs every time someone asks a question:</p>
<ol>
<li><p>Embed the user's question using the same model</p>
</li>
<li><p>Search the database for chunks whose vectors are closest to the question vector</p>
</li>
<li><p>Take the top 5 matching chunks and assemble them into a context block</p>
</li>
<li><p>Send <code>context + question</code> to the LLM and return its answer</p>
</li>
</ol>
<p>The reason this works better than keyword search: the embedding model captures <em>meaning</em>, not just exact words. If your doc says "terminate the process" and the user asks "how do I stop it?", vector similarity finds that match. Regular string matching doesn't.</p>
<p>One thing that trips people up: you must use the same embedding model at query time as you did at ingestion. The model defines the geometric space those vectors live in. Switch models halfway through and the coordinates stop meaning the same thing — you'd be comparing apples to completely different apples.</p>
<h2 id="heading-what-were-building">What We're Building</h2>
<p>The architecture is intentionally minimal: two endpoints, with nothing you don't need:</p>
<ul>
<li><p><code>POST /ingest</code>: accepts a PDF upload, chunks it, embeds each chunk, stores vectors in pgvector</p>
</li>
<li><p><code>POST /chat</code>: accepts a question, retrieves the most relevant chunks, returns an LLM-generated answer</p>
</li>
</ul>
<p>The full tech stack:</p>
<ul>
<li><p><strong>Node.js + Express</strong> — API layer</p>
</li>
<li><p><strong>Google Gemini free API</strong> — <code>gemini-embedding-001</code> for embeddings (3,072 dimensions per chunk)</p>
</li>
<li><p><strong>Groq free API</strong> — <code>llama-3.1-8b-instant</code> for text generation</p>
</li>
<li><p><strong>PostgreSQL + pgvector</strong> — vector storage and cosine similarity search, running in Docker</p>
</li>
<li><p><strong>pdf-parse</strong> — extracts raw text from PDF buffers</p>
</li>
</ul>
<p>Gemini handles embeddings and Groq handles generation. Splitting them across two providers isn't arbitrary. Gemini's generation API has a quota limit of zero in certain regions (including Pakistan), while Groq works everywhere with no restrictions. Using Groq for generation means this tutorial runs the same way regardless of where you are.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start:</p>
<ul>
<li><p>Node.js 20+ installed on your machine</p>
</li>
<li><p>Docker Desktop running (this is how we'll run Postgres locally)</p>
</li>
<li><p>A free Google Gemini API key (for embeddings)</p>
</li>
<li><p>A free Groq API key (for text generation)</p>
</li>
</ul>
<h3 id="heading-how-to-get-your-free-gemini-api-key">How to Get Your Free Gemini API Key</h3>
<ol>
<li><p>Go to <a href="https://aistudio.google.com/app/apikey">aistudio.google.com/app/apikey</a> and sign in with a Google account</p>
</li>
<li><p>Click "Create API key"</p>
</li>
<li><p>Select "Create API key in new project"</p>
</li>
<li><p>Copy the key — it starts with <code>AIzaSy...</code></p>
</li>
</ol>
<p>No credit card or billing required.</p>
<h3 id="heading-how-to-get-your-free-groq-api-key">How to Get Your Free Groq API Key</h3>
<ol>
<li><p>Go to <a href="https://console.groq.com">console.groq.com</a> and sign up with Google</p>
</li>
<li><p>Click "API Keys" in the left sidebar</p>
</li>
<li><p>Click "Create API Key", give it a name, copy the key — it starts with <code>gsk_...</code></p>
</li>
</ol>
<p>Groq is free with generous rate limits and works in all regions.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Create the project directory and initialize it:</p>
<pre><code class="language-bash">mkdir nodejs-rag-chatbot
cd nodejs-rag-chatbot
npm init -y
</code></pre>
<p>Install dependencies:</p>
<pre><code class="language-bash">npm install express pg pdf-parse uuid dotenv multer
npm install --save-dev nodemon
</code></pre>
<p>A quick note on the packages: <code>multer</code> is what makes file uploads work on the <code>/ingest</code> endpoint. Without it, Express can't parse multipart form data.</p>
<p><code>pdf-parse</code> does the heavy lifting on PDFs, though watch out for scanned PDFs. Those are just images with no text layer underneath, so you'll get back an empty string.</p>
<p><code>pg</code> talks to Postgres, <code>uuid</code> gives each row a unique ID, and <code>dotenv</code> loads your keys before the app does anything.</p>
<p>Create a <code>.env</code> in the project root. It needs seven values:</p>
<pre><code class="language-plaintext">GEMINI_API_KEY=AIzaSy...         ← your Gemini key from Google AI Studio
GROQ_API_KEY=gsk_...             ← your Groq key from console.groq.com
POSTGRES_USER=rag_user
POSTGRES_PASSWORD=rag_pass       ← choose any password, this is local only
POSTGRES_DB=rag_db
DATABASE_URL=postgresql://rag_user:rag_pass@localhost:5432/rag_db
PORT=3000
</code></pre>
<p>One thing: the password in <code>POSTGRES_PASSWORD</code> and the one in <code>DATABASE_URL</code> must match exactly. I changed just one of them once and spent way too long debugging a "password authentication failed" error before realising the two values were out of sync.</p>
<p>Update <code>package.json</code> scripts:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}
</code></pre>
<p>Create the <code>src</code> directory:</p>
<pre><code class="language-bash">mkdir src
</code></pre>
<p>Your final folder structure will look like this:</p>
<pre><code class="language-plaintext">nodejs-rag-chatbot/
├── src/
│   ├── index.js        ← Express app entry point
│   ├── db.js           ← Postgres connection and schema setup
│   ├── embeddings.js   ← Gemini embedding + Groq generation
│   ├── ingest.js       ← Document ingestion pipeline
│   └── query.js        ← RAG query pipeline
├── docker-compose.yml
├── .env
└── package.json
</code></pre>
<h2 id="heading-set-up-postgres-with-pgvector-using-docker">Set Up Postgres with pgvector Using Docker</h2>
<p>pgvector adds a <code>vector</code> column type to Postgres and the operators needed to search it by similarity. Normally you'd have to install it yourself, but the <code>pgvector/pgvector</code> Docker image ships with it already baked in. Just pull the image and you're good.</p>
<p>Now add <code>docker-compose.yml</code> to the project root:</p>
<pre><code class="language-yaml">services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
</code></pre>
<p>Those <code>${VARIABLE}</code> references get swapped out from <code>.env</code> when Compose starts — so <code>docker-compose.yml</code> itself stays clean. This is worth doing from day one. I've seen people skip this and regret it after a repo goes public.</p>
<p>Start it:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<h2 id="heading-connect-to-the-database">Connect to the Database</h2>
<p>Create <code>src/db.js</code>. This sets up the connection pool and creates the <code>documents</code> table on first run:</p>
<pre><code class="language-javascript">const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

async function initDb() {
  await pool.query(`CREATE EXTENSION IF NOT EXISTS vector`);

  await pool.query(`
    CREATE TABLE IF NOT EXISTS documents (
      id UUID PRIMARY KEY,
      content TEXT NOT NULL,
      source TEXT NOT NULL,
      embedding VECTOR(3072)
    )
  `);

  console.log('Database ready');
}

module.exports = { pool, initDb };
</code></pre>
<p>The <code>VECTOR(3072)</code> dimension matches the output of Gemini's <code>gemini-embedding-001</code> model exactly. If you use a different embedding model in the future, check its output dimensions and update this number to match.</p>
<h2 id="heading-build-the-ingestion-pipeline">Build the Ingestion Pipeline</h2>
<p>Start with <code>embeddings.js</code>. This file is the bridge to both external APIs — Gemini for turning text into vectors, Groq for generating the final answer. Keeping both in one place means a single file to touch if you ever swap providers.</p>
<p><strong>src/embeddings.js:</strong></p>
<pre><code class="language-javascript">const GEMINI_KEY = process.env.GEMINI_API_KEY;
const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1/models';

async function embedText(text) {
  const res = await fetch(
    `${GEMINI_BASE}/gemini-embedding-001:embedContent?key=${GEMINI_KEY}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: { parts: [{ text }] } }),
    }
  );
  const data = await res.json();
  if (!res.ok) throw new Error(JSON.stringify(data));
  return data.embedding.values;
}

async function generateAnswer(context, question) {
  const res = await fetch(
    'https://api.groq.com/openai/v1/chat/completions',
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.GROQ_API_KEY}`,
      },
      body: JSON.stringify({
        model: 'llama-3.1-8b-instant',
        messages: [
          {
            role: 'system',
            content: 'You are a helpful assistant. Answer the question using only the context provided. If the context does not contain enough information, say so clearly.',
          },
          {
            role: 'user',
            content: `Context:\n${context}\n\nQuestion: ${question}`,
          },
        ],
      }),
    }
  );
  const data = await res.json();
  if (!res.ok) throw new Error(JSON.stringify(data));
  return data.choices[0].message.content;
}

module.exports = { embedText, generateAnswer };
</code></pre>
<p>We're calling both APIs directly with Node.js's built-in <code>fetch</code> rather than the official SDKs. The reason is practical: Google's Node.js SDK routes requests through the <code>v1beta</code> endpoint by default, and <code>gemini-embedding-001</code> isn't available there — only on <code>v1</code>. Direct fetch sidesteps that entirely and keeps the dependency count low.</p>
<p><strong>src/ingest.js:</strong></p>
<pre><code class="language-javascript">const pdfParse = require('pdf-parse');
const { v4: uuidv4 } = require('uuid');
const { pool } = require('./db');
const { embedText } = require('./embeddings');

function chunkText(text, chunkSize = 500, overlap = 50) {
  const chunks = [];
  let start = 0;

  while (start &lt; text.length) {
    const end = Math.min(start + chunkSize, text.length);
    chunks.push(text.slice(start, end).trim());
    start += chunkSize - overlap;
  }

  return chunks.filter(chunk =&gt; chunk.length &gt; 50);
}

async function ingestDocument(buffer, filename) {
  const { text } = await pdfParse(buffer);
  const chunks = chunkText(text);

  console.log(`Processing ${chunks.length} chunks from "${filename}"`);

  for (const chunk of chunks) {
    const embedding = await embedText(chunk);

    await pool.query(
      `INSERT INTO documents (id, content, source, embedding)
       VALUES ($1, $2, $3, $4::vector)`,
      [uuidv4(), chunk, filename, JSON.stringify(embedding)]
    );
  }

  return chunks.length;
}

module.exports = { ingestDocument };
</code></pre>
<p>500 characters per chunk, with 50 characters of overlap between neighbours.</p>
<p>Why the overlap? Without it, a sentence that straddles a boundary gets split, half in one chunk, half in the next — and neither piece makes sense on its own when retrieved. The overlap keeps those boundary sentences intact.</p>
<p>For most technical docs, 500 is a good starting point. Dense legal or financial text tends to need something closer to 300.</p>
<h2 id="heading-build-the-query-pipeline">Build the Query Pipeline</h2>
<p><strong>src/query.js:</strong></p>
<pre><code class="language-javascript">const { pool } = require('./db');
const { embedText, generateAnswer } = require('./embeddings');

async function queryDocuments(question) {
  const questionEmbedding = await embedText(question);

  const { rows } = await pool.query(
    `SELECT content, source,
            1 - (embedding &lt;=&gt; $1::vector) AS similarity
     FROM documents
     ORDER BY embedding &lt;=&gt; $1::vector
     LIMIT 5`,
    [JSON.stringify(questionEmbedding)]
  );

  if (rows.length === 0) {
    return { answer: 'No relevant documents found.', sources: [] };
  }

  const context = rows.map(r =&gt; r.content).join('\n\n---\n\n');
  const answer = await generateAnswer(context, question);

  return {
    answer,
    sources: [...new Set(rows.map(r =&gt; r.source))],
    topSimilarity: parseFloat(rows[0].similarity).toFixed(3),
  };
}

module.exports = { queryDocuments };
</code></pre>
<p>The <code>&lt;=&gt;</code> operator is pgvector's cosine distance. Semantically similar text produces vectors that point in the same direction — so the distance between them is small. Flip that with <code>1 - distance</code> and you get a similarity score, where anything close to 1 means a strong match.</p>
<p>I found 0.7 to be a reliable threshold in my testing — chunks above that were almost always relevant. Anything below 0.5 and the retrieval was really stretching, pulling chunks that shared a keyword or two but weren't actually answering the question.</p>
<p>When that happens, the system prompt instruction ("if the context does not contain enough information, say so clearly") becomes important. A well-behaved model will tell the user it doesn't know rather than guess.</p>
<p>We also surface the source filename. Once you've ingested more than one document, users need to know whether that answer came from the architecture spec or the incident report.</p>
<h2 id="heading-build-the-chat-api-with-express">Build the Chat API with Express</h2>
<p><strong>src/index.js:</strong></p>
<pre><code class="language-javascript">require('dotenv').config();
const express = require('express');
const multer = require('multer');
const { initDb } = require('./db');
const { ingestDocument } = require('./ingest');
const { queryDocuments } = require('./query');

const app = express();
const upload = multer({ storage: multer.memoryStorage() });

app.use(express.json());

app.post('/ingest', upload.single('file'), async (req, res) =&gt; {
  if (!req.file) {
    return res.status(400).json({ error: 'No file uploaded' });
  }

  if (!req.file.mimetype.includes('pdf')) {
    return res.status(400).json({ error: 'Only PDF files are supported' });
  }

  try {
    const count = await ingestDocument(req.file.buffer, req.file.originalname);
    res.json({ message: `Ingested ${count} chunks from "${req.file.originalname}"` });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Ingestion failed', detail: err.message });
  }
});

app.post('/chat', async (req, res) =&gt; {
  const { question } = req.body;

  if (!question || typeof question !== 'string') {
    return res.status(400).json({ error: 'question is required' });
  }

  try {
    const result = await queryDocuments(question);
    res.json(result);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Query failed', detail: err.message });
  }
});

const PORT = process.env.PORT || 3000;

initDb().then(() =&gt; {
  app.listen(PORT, () =&gt; {
    console.log(`RAG chatbot running on port ${PORT}`);
  });
});
</code></pre>
<p><code>memoryStorage()</code> keeps the uploaded file in a buffer instead of writing it to disk. We parse it and store the chunks immediately, so there's nothing to save.</p>
<h2 id="heading-test-the-chatbot">Test the Chatbot</h2>
<p>Start the server:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Database ready
RAG chatbot running on port 3000
</code></pre>
<p>Upload a PDF. Any PDF works. I tested with a copy of a Node.js best practices guide:</p>
<pre><code class="language-bash"># Linux / macOS
curl -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"

# Windows PowerShell
curl.exe -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{ "message": "Ingested 142 chunks from \"your-document.pdf\"" }
</code></pre>
<p>Now ask a question:</p>
<pre><code class="language-bash"># Linux / macOS
curl -X POST http://localhost:3000/chat \
  -H "Content-Type: application/json" \
  -d '{ "question": "How should I handle errors in async functions?" }'

# Windows PowerShell
curl.exe -X POST http://localhost:3000/chat -H "Content-Type: application/json" -d "{\"question\": \"How should I handle errors in async functions?\"}"
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "answer": "For async functions in Node.js, wrap your logic in a try/catch block to handle rejected promises. In Express, pass the caught error to next(err) to trigger your error-handling middleware. Alternatively, you can create a wrapper function that wraps any async route handler in a promise and calls next on rejection, keeping your route handlers clean...",
  "sources": ["your-document.pdf"],
  "topSimilarity": "0.841"
}
</code></pre>
<p>The <code>topSimilarity</code> score tells you how well the retrieval went. Above 0.7 and the chunks pulled were genuinely relevant. Below 0.5, and the search was struggling: it found something, but not something that actually answers the question.</p>
<p>Try asking about something your PDF doesn't mention. If the system prompt is doing its job, the model should say it doesn't have enough information rather than making something up. That's the behaviour you want in production.</p>
<p>The repo includes two diagnostic scripts that are useful if anything isn't working:</p>
<ul>
<li><p><code>node test-keys.js</code> — tests both API keys live and reports whether each one succeeds</p>
</li>
<li><p><code>node list-models.js</code> — fetches the full list of Gemini models available to your API key</p>
</li>
</ul>
<p>Run these before diving into the troubleshooting section below.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>Everything in this section is a real error I hit while building this. Nothing hypothetical.</p>
<h3 id="heading-port-5432-is-already-in-use">Port 5432 is already in use</h3>
<pre><code class="language-plaintext">Error: bind: address already in use
</code></pre>
<p>Something else — probably a local Postgres install — is already on that port. Two fixes are needed. First, in <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">ports:
  - "5433:5432"
</code></pre>
<p>Second, update <code>DATABASE_URL</code> in <code>.env</code>:</p>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://rag_user:rag_pass@localhost:5433/rag_db
</code></pre>
<p>The container itself still listens on 5432 internally. You're just changing which port your machine uses to reach it.</p>
<h3 id="heading-password-authentication-failed-for-user-raguser">Password authentication failed for user "rag_user"</h3>
<pre><code class="language-plaintext">Error: password authentication failed for user "rag_user"
</code></pre>
<p>The password Postgres was initialized with doesn't match what your app is sending. Open <code>.env</code> and compare <code>POSTGRES_PASSWORD</code> with the password embedded in <code>DATABASE_URL</code>. They need to be character-for-character identical.</p>
<p>After fixing the mismatch, the old volume still has the wrong password baked into it. You must destroy it and start fresh:</p>
<pre><code class="language-bash">docker compose down -v
docker compose up -d
</code></pre>
<p>The <code>-v</code> flag deletes the data volume. Postgres reinitializes on the next start with the credentials from your current <code>.env</code>.</p>
<h3 id="heading-gemini-model-not-found-404">Gemini model not found (404)</h3>
<pre><code class="language-json">{ "error": { "code": 404, "message": "models/text-embedding-004 is not found" } }
</code></pre>
<p>The Google AI model naming has changed. Older tutorials and blog posts reference model names that no longer exist on the v1 endpoint. The correct model for this stack is <code>gemini-embedding-001</code>. That's what this repo uses.</p>
<p>If you want to see every model available to your API key, run:</p>
<pre><code class="language-bash">node list-models.js
</code></pre>
<p>That script fetches the live list directly from the API so you're not guessing.</p>
<h3 id="heading-vector-dimension-mismatch">Vector dimension mismatch</h3>
<pre><code class="language-plaintext">ERROR: expected 768 dimensions, not 3072
</code></pre>
<p>This error appears when your database table was created with a different dimension count than what your embedding model produces. <code>gemini-embedding-001</code> outputs 3,072-dimensional vectors. The <code>documents</code> table in this tutorial uses <code>VECTOR(3072)</code> to match.</p>
<p>If you get this error, it means either an old table exists with the wrong dimension, or you changed embedding models without recreating the table. Drop the data volume and restart:</p>
<pre><code class="language-bash">docker compose down -v
docker compose up -d
</code></pre>
<h3 id="heading-vector-index-dimension-limit">Vector index dimension limit</h3>
<pre><code class="language-plaintext">ERROR: ivfflat index type only supports up to 2000 dimensions
</code></pre>
<p>pgvector's <code>ivfflat</code> and <code>hnsw</code> index types have a maximum dimension of 2000. Since <code>gemini-embedding-001</code> produces 3,072-dimensional vectors, neither index type works.</p>
<p>This tutorial drops the index and lets pgvector do a full scan — fine for development and any reasonably sized corpus. Scaling to thousands of documents in production? Pick a model under 2000 dimensions. OpenAI's <code>text-embedding-3-small</code> outputs 1536 and plays nicely with both index types.</p>
<h3 id="heading-port-3000-is-already-in-use">Port 3000 is already in use</h3>
<pre><code class="language-plaintext">Error: EADDRINUSE: address already in use :::3000
</code></pre>
<p>Some other process got there first. Swap the port number in <code>.env</code>:</p>
<pre><code class="language-plaintext">PORT=3002
</code></pre>
<p>Save it and restart the server.</p>
<h3 id="heading-gemini-generation-returns-quota-exceeded-limit-0">Gemini generation returns quota exceeded (limit: 0)</h3>
<pre><code class="language-json">{ "error": { "status": "RESOURCE_EXHAUSTED", "message": "Quota exceeded for quota metric ... with limit 0" } }
</code></pre>
<p>That <code>limit 0</code> means Google has switched off free generation in your country entirely — not that you've used it up. I hit this myself while testing from Pakistan.</p>
<p>That's exactly why this tutorial uses Groq instead. Make sure <code>GROQ_API_KEY</code> is in your <code>.env</code> and that <code>generateAnswer</code> in <code>src/embeddings.js</code> is pointing at <code>api.groq.com</code>.</p>
<p>To verify both keys work, run:</p>
<pre><code class="language-bash">node test-keys.js
</code></pre>
<p>It tests the Gemini embedding endpoint and the Groq generation endpoint independently and reports whether each succeeds.</p>
<h3 id="heading-nodemon-doesnt-pick-up-changes-to-env">nodemon doesn't pick up changes to <code>.env</code></h3>
<p>nodemon only watches <code>.js</code> files — <code>.env</code> changes don't trigger a restart. Switch to the terminal running the server and type <code>rs</code>, then hit Enter. That forces a restart and picks up whatever you changed.</p>
<h3 id="heading-curl-doesnt-work-in-windows-powershell"><code>curl</code> doesn't work in Windows PowerShell</h3>
<pre><code class="language-plaintext">curl : The term 'curl' is not recognized
</code></pre>
<p>or</p>
<pre><code class="language-plaintext">curl : Cannot bind parameter because parameter 'Method' is specified more than once
</code></pre>
<p>PowerShell has a built-in <code>curl</code> alias that points to <code>Invoke-WebRequest</code> — completely different flags, completely different behaviour. Add <code>.exe</code> and you bypass the alias and hit the real binary.</p>
<p>So instead of <code>curl</code>, type <code>curl.exe</code>:</p>
<pre><code class="language-powershell"># Ingest
curl.exe -X POST http://localhost:3000/ingest -F "file=@your-document.pdf"

# Chat
curl.exe -X POST http://localhost:3000/chat -H "Content-Type: application/json" -d "{\"question\": \"How do I handle async errors?\"}"
</code></pre>
<p>That <code>.exe</code> is the whole fix.</p>
<h3 id="heading-docker-desktop-stopped-running">Docker Desktop stopped running</h3>
<p>Docker Desktop doesn't start automatically after a reboot on most setups. If your Docker commands suddenly fail with connection errors, that's probably why. Open Docker Desktop, wait until it says "Engine running", then try again.</p>
<h2 id="heading-how-to-swap-in-openai">How to Swap in OpenAI</h2>
<p>If you want to use the OpenAI API instead of Gemini, it's three changes.</p>
<p>1. Install the OpenAI SDK:</p>
<pre><code class="language-bash">npm install openai
</code></pre>
<p>2. Replace <code>src/embeddings.js</code> entirely:</p>
<pre><code class="language-javascript">const OpenAI = require('openai');

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function embedText(text) {
  const result = await client.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  });
  return result.data[0].embedding;
}

async function generateAnswer(context, question) {
  const result = await client.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [
      { role: 'system', content: 'Answer only from the context provided. If the context is insufficient, say so.' },
      { role: 'user', content: `Context:\n${context}\n\nQuestion: ${question}` },
    ],
  });
  return result.choices[0].message.content;
}

module.exports = { embedText, generateAnswer };
</code></pre>
<p>3. Update the vector dimension in <code>src/db.js</code>:</p>
<p>Open <code>db.js</code> and swap <code>VECTOR(3072)</code> for <code>VECTOR(1536)</code> — that's the output size of <code>text-embedding-3-small</code>. Then kill the volume so the table gets recreated with the right dimensions:</p>
<pre><code class="language-bash">docker compose down -v
docker compose up -d
</code></pre>
<p>Nothing else needs touching. The ingestion and query logic works the same regardless of which model you plugged in.</p>
<h2 id="heading-what-to-build-next">What to Build Next</h2>
<p>What you've built works. But there are some gaps that come up quickly once you put it in front of real users.</p>
<p>The most noticeable one is <strong>streaming</strong>. Right now <code>/chat</code> holds the connection open until Groq finishes generating the full answer, then returns everything at once. On a short question that's fine. On a longer one, the user stares at nothing for a few seconds and wonders if the request hung.</p>
<p>The Groq API supports streaming — add <code>stream: true</code> to the request body and tokens start coming back as they're generated. Piping those through Express with <code>res.write()</code> is maybe 15 minutes of work and the difference in feel is immediate.</p>
<p><strong>Metadata filtering</strong> is the second thing you'll want. Once you've loaded more than a few documents, queries bleed across everything: ask about the API spec and you'll get chunks from the onboarding guide too.</p>
<p>The fix is a <code>metadata JSONB</code> column where you store the document ID on ingest, then add <code>WHERE metadata-&gt;&gt;'doc_id' = $1</code> to the similarity query. Expose it as an optional body field on <code>/chat</code>: <code>{ "question": "...", "docId": "api-spec-v2" }</code>. Users get scoped results, and you get much cleaner answers.</p>
<p>When your corpus grows into the hundreds of documents, look at <strong>re-ranking</strong>. Vector similarity retrieval is fast but approximate — it finds chunks that are semantically close to the question, not necessarily the ones that most directly answer it.</p>
<p>The pattern is: retrieve the top 20 by cosine distance, then run a cross-encoder over them to re-score by actual relevance, then take the best 5 from that second pass. LangChain.js has a cross-encoder wrapper if you don't want to implement it yourself.</p>
<p>The last thing most people forget until they actually need it is <strong>document management</strong> — the ability to list what's ingested, delete a specific file, and re-ingest an updated version.</p>
<p>A <code>DELETE FROM documents WHERE source = $1</code> handles the delete case. Add a <code>GET /documents</code> endpoint that queries <code>SELECT DISTINCT source FROM documents</code> and you have a complete enough API for real use.</p>
<p>RAG isn't magic. It's a well-scoped retrieval problem combined with a language model that's been told to stay within its lane.</p>
<p>The quality of your answers depends on three things: how cleanly your PDFs parse, how well your chunk size fits the content type, and how clearly your system prompt instructs the model to say "I don't know" rather than guess. Get those right and you've built something genuinely useful: the kind of thing that saves a new engineer's first two weeks.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
