<?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[ JavaScript - 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[ JavaScript - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Wed, 04 Nov 2026 06:33:31 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/javascript/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build Disaster Simulations on a Real Map: Animated Canvas Layers and Collapsing 3D Buildings with Mapbox GL JS and Three.js ]]>
                </title>
                <description>
                    <![CDATA[ At some point, a map project needs more than markers and polygons. You want something to happen on the map: water moving up a street, smoke drifting with the wind, or a shockwave spreading out from a  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/disaster-simulation-real-map-mapbox-three-js/</link>
                <guid isPermaLink="false">6ad05935fb11435ce8281161</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ canvas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mapbox ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Oliinyk ]]>
                </dc:creator>
                <pubDate>Thu, 15 Oct 2026 04:40:21 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/db541c6a-aba6-4a25-b0f8-2a4657c485d3.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>At some point, a map project needs more than markers and polygons. You want something to happen on the map: water moving up a street, smoke drifting with the wind, or a shockwave spreading out from a point.</p>
<p>The first time I tried this, I did what seemed obvious to me. I put a <code>&lt;canvas&gt;</code> on top of the map and drew my animation on it.</p>
<p>But it looked wrong in a way I couldn't fix with more polish. The effect had edges. It floated above the ground. And as soon as I tilted or rotated the map, it stopped lining up with anything underneath.</p>
<p>I hit this while building <a href="https://hazardsim.com/">HazardSim</a>, a browser simulator that runs natural disasters on a real map. The tsunami scenario went through about a dozen rewrites before it stopped looking like a cut-out pasted over the city.</p>
<p>Much of the testing, and a good share of the bug reports, came from my 12-year-old son Tim, who turned out to be a more patient tester than I am. Most of those rewrites taught me the same thing from a different direction: <strong>draw your simulation in map coordinates, not screen coordinates.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/a7c17a37-4c30-48a9-be44-89da78f6c14f.jpg" alt="Left: a video clip of a breaking wave drawn on a canvas overlaid on the map. It is a rectangle floating over Odesa with visible soft edges. Right: the same second of the simulation with the water painted into a canvas that the map treats as a raster source. The water follows the coastline and the tilt of the camera." style="display: block;" width="600" height="400" loading="lazy">

<p>That rule carried me through the tsunami. Then I started on the earthquake and ran into the opposite problem. Mapbox GL JS will draw a city in 3D for you: turn on a <code>fill-extrusion</code> layer and every footprint in the vector tiles rises to its real height.</p>
<p>It looks good until you try to make one of those buildings fall down, and you find out that the map's 3D is a rendering trick, not a scene. There are no objects to grab, rotate or break. Things that lie on the ground and things that have height turned out to need two different techniques, and this guide covers both.</p>
<p><strong>Part 1</strong> builds the ground layer. You'll set up a grid aligned to the map's own tiles, load real elevation data into it, run a flood fill that respects hills and valleys, hand the canvas to the map as a raster source, write pixels into it every frame, layer video textures on top with canvas compositing modes, and move the camera into the scene.</p>
<p><strong>Part 2</strong> adds height. You'll pull building footprints and heights out of the map's tiles, make the extrusions look like buildings, decide which ones fall from the magnitude and distance, animate the collapse without rebuilding any layers, set up a <code>three.js</code> custom layer inside the map's own WebGL context, turn a footprint into a box that tips over, throw debris and dust, and shake the camera.</p>
<p>At the end we'll go through the mistakes from both halves, several of which produce no error message and cost me a day each.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with JavaScript, <code>requestAnimationFrame</code>, and the basics of the 2D canvas API: <code>getContext("2d")</code>, <code>drawImage</code>, and <code>ImageData</code>.</p>
<p>You don't need any background in web maps. Everything map-specific is explained where it comes up.</p>
<p>Part 2 also assumes you've used <code>three.js</code> at least once: meshes, materials, a scene and a camera. If you haven't, the three.js fundamentals are enough to follow along, and the parts that interact with the map are explained in full.</p>
<p>The examples use <a href="https://docs.mapbox.com/mapbox-gl-js/">Mapbox GL JS</a>, which is what runs on HazardSim. You'll need a free Mapbox access token for the base map and the vector tiles. Every API used here also exists in <a href="https://maplibre.org/">MapLibre GL JS</a>, the open-source fork, under the same names. The one place the two differ is called out in Part 2.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-part-1-drawing-on-the-ground">Part 1: Drawing on the Ground</a></p>
</li>
<li><p><a href="#heading-the-core-idea-map-space-not-screen-space">The Core Idea: Map Space, Not Screen Space</a></p>
</li>
<li><p><a href="#heading-step-1-choose-a-grid-aligned-to-map-tiles">Step 1: Choose a Grid Aligned to Map Tiles</a></p>
</li>
<li><p><a href="#heading-step-2-load-real-elevation-into-the-grid">Step 2: Load Real Elevation Into the Grid</a></p>
</li>
<li><p><a href="#heading-step-3-attach-a-canvas-as-a-map-source">Step 3: Attach a Canvas as a Map Source</a></p>
</li>
<li><p><a href="#heading-step-4-simulate-a-flood-that-respects-the-terrain">Step 4: Simulate a Flood That Respects the Terrain</a></p>
</li>
<li><p><a href="#heading-step-5-animate-by-writing-pixels">Step 5: Animate by Writing Pixels</a></p>
</li>
<li><p><a href="#heading-step-6-layer-video-textures-with-canvas-compositing">Step 6: Layer Video Textures With Canvas Compositing</a></p>
</li>
<li><p><a href="#heading-step-7-move-the-camera-into-the-scene">Step 7: Move the Camera Into the Scene</a></p>
</li>
<li><p><a href="#heading-where-the-ground-layer-stops">Where the Ground Layer Stops</a></p>
</li>
<li><p><a href="#heading-part-2-things-with-height">Part 2: Things with Height</a></p>
</li>
<li><p><a href="#heading-two-kinds-of-3d-and-why-you-need-both">Two Kinds of 3D, and Why You Need Both</a></p>
</li>
<li><p><a href="#heading-step-8-get-buildings-out-of-the-maps-own-tiles">Step 8: Get Buildings Out of the Map's Own Tiles</a></p>
</li>
<li><p><a href="#heading-step-9-make-extrusions-look-like-buildings">Step 9: Make Extrusions Look Like Buildings</a></p>
</li>
<li><p><a href="#heading-step-10-decide-what-falls">Step 10: Decide What Falls</a></p>
</li>
<li><p><a href="#heading-step-11-animate-collapse-without-rebuilding-layers">Step 11: Animate Collapse Without Rebuilding Layers</a></p>
</li>
<li><p><a href="#heading-step-12-a-threejs-scene-inside-the-map">Step 12: A three.js Scene Inside the Map</a></p>
</li>
<li><p><a href="#heading-step-13-from-footprint-to-a-box-that-tips-over">Step 13: From Footprint to a Box That Tips Over</a></p>
</li>
<li><p><a href="#heading-step-14-debris-and-dust">Step 14: Debris and Dust</a></p>
</li>
<li><p><a href="#heading-step-15-the-camera-has-to-shake">Step 15: The Camera Has to Shake</a></p>
</li>
<li><p><a href="#heading-keeping-it-fast">Keeping It Fast</a></p>
</li>
<li><p><a href="#heading-what-went-wrong-and-why">What Went Wrong, and Why</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-part-1-drawing-on-the-ground">Part 1: Drawing on the Ground</h2>
<p>The first half of this guide is about anything that belongs to the surface of the map: water, fire spread, a smoke stain, or a shockwave. The technique is one idea applied carefully, and the next seven steps build it up from a grid to a camera move.</p>
<h2 id="heading-the-core-idea-map-space-not-screen-space">The Core Idea: Map Space, Not Screen Space</h2>
<p>A web map is a camera looking at a surface. When the user drags, zooms, tilts or rotates, the camera moves and the surface stays where it is. Roads, buildings, and your flood all belong to the surface, so the camera has to transform them together.</p>
<p>A canvas placed over the map's DOM element is attached to the camera, not to the surface. Think of it as a sheet of glass in front of the lens. Whatever you draw on it stays where you drew it while the world moves underneath.</p>
<p>At a top-down zoom with no rotation, you can fake alignment by reprojecting your points every frame, and it almost works. Add pitch and it falls apart: a flat sprite has no perspective, no foreshortening, and nothing in front of or behind it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/fbcc5755-b979-4e21-86f4-79f16e27bbc9.png" alt="Two diagrams side by side. Left, titled &quot;Canvas on top of the map&quot;: a tilted map surface with a flat blue rectangle standing upright over it, labelled &quot;flat sprite&quot;, with the note &quot;no perspective, visible edges, doesn't rotate with the map&quot;. Right, titled &quot;Canvas as a map source&quot;: the same tilted surface with the blue area lying flat on it as a trapezoid, labelled &quot;same pixels, foreshortened&quot;, with the note &quot;perspective for free, no edges, rotates, tilts, follows terrain&quot;." style="display: block;" width="600" height="400" loading="lazy">

<p>The fix is to give your canvas to the map as a data source. Mapbox GL JS has a <code>canvas</code> source type. You pass it a canvas element and four geographic corners, and from then on the map drapes that canvas over the rectangle the same way it drapes satellite imagery. Rotate the map and the canvas rotates. Tilt it and the canvas foreshortens. Turn on 3D terrain and the canvas follows the hills.</p>
<p>Once the drawing surface lives in map space, several hard problems disappear. The rest of this handbook is about what you can do with that.</p>
<h2 id="heading-step-1-choose-a-grid-aligned-to-map-tiles">Step 1: Choose a Grid Aligned to Map Tiles</h2>
<p>Your simulation needs a grid: a 2D array of cells, each holding elevation, water depth, or whatever your model tracks. The decision that makes everything downstream simpler is to align that grid to the map's own tile pyramid.</p>
<p>Web maps use the Web Mercator projection, cut into 256×256-pixel tiles at zoom levels 0 to 22. At zoom <code>z</code> the world is <code>2^z</code> tiles wide. If one grid cell is exactly one tile pixel at some zoom, converting between grid indices and longitude/latitude takes four short functions, and every dataset that ships as tiles (elevation, population, imagery) drops into your grid without resampling.</p>
<p>Here are the four conversions. <code>lon2x</code> and <code>lat2y</code> return fractional tile coordinates. Multiply by 256 to get pixels.</p>
<pre><code class="language-javascript">function lon2x(lon, z) {
  return (lon + 180) / 360 * Math.pow(2, z);
}

function lat2y(lat, z) {
  const rad = lat * Math.PI / 180;
  return (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2 * Math.pow(2, z);
}

function x2lon(x, z) {
  return x / Math.pow(2, z) * 360 - 180;
}

function y2lat(y, z) {
  const n = Math.PI - 2 * Math.PI * y / Math.pow(2, z);
  return 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}
</code></pre>
<p>Next, pick the zoom. You want the area of interest to fit in a grid that's fine enough to look smooth but small enough to iterate over sixty times a second. In HazardSim, I start at zoom 15 and step down until the bounding box fits in roughly 780 pixels across and under about 620,000 cells:</p>
<pre><code class="language-javascript">function chooseZoom(bbox) {
  for (let z = 15; z &gt;= 8; z--) {
    const pw = (lon2x(bbox.e, z) - lon2x(bbox.w, z)) * 256;
    const ph = (lat2y(bbox.s, z) - lat2y(bbox.n, z)) * 256;
    if (pw &lt;= 780 &amp;&amp; pw * ph &lt;= 620000) return z;
  }
  return 8;
}
</code></pre>
<p>With the zoom chosen, snap the bounding box outward to whole tiles. That rectangle is what your canvas will cover, and those are the tiles you'll download:</p>
<pre><code class="language-javascript">function gridFor(bbox) {
  const z = chooseZoom(bbox);
  const x0 = Math.floor(lon2x(bbox.w, z)), x1 = Math.floor(lon2x(bbox.e, z));
  const y0 = Math.floor(lat2y(bbox.n, z)), y1 = Math.floor(lat2y(bbox.s, z));
  const nx = x1 - x0 + 1, ny = y1 - y0 + 1;
  const latMid = (bbox.n + bbox.s) / 2;

  return {
    z, x0, y0, nx, ny,
    W: nx * 256,
    H: ny * 256,
    // metres per cell at this latitude and zoom
    cellM: 156543.03392 * Math.cos(latMid * Math.PI / 180) / Math.pow(2, z),
    // exact geographic corners of the snapped rectangle
    corners: {
      w: x2lon(x0, z), e: x2lon(x1 + 1, z),
      n: y2lat(y0, z), s: y2lat(y1 + 1, z),
    },
  };
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/2b47daac-24cc-42e9-b6f0-fb110bb13aa8.png" alt="Diagram of a 4 by 3 arrangement of map tiles with orange tile borders. A dashed blue rectangle inside marks the city bounding box. One tile is subdivided into small cells to show that one grid cell is one tile pixel. A side panel lists why aligning to tiles helps and shows the conversion formulas." style="display: block;" width="600" height="400" loading="lazy">

<p>Keep <code>cellM</code> around. You'll need it to turn "the wave has travelled 400 metres" into "the wave has travelled 3.8 cells". For Odesa, the city in most of the screenshots here, the grid ends up at zoom 10 with 105-metre cells.</p>
<h2 id="heading-step-2-load-real-elevation-into-the-grid">Step 2: Load Real Elevation Into the Grid</h2>
<p>A flood that ignores hills is a blue blob. What makes a simulation on a real map feel real is real terrain, and terrain is free.</p>
<p>The AWS Open Data program hosts the Mapzen <strong>Terrarium</strong> elevation tiles. They're ordinary PNGs with height encoded in the pixel colour:</p>
<pre><code class="language-javascript">height_m = R * 256 + G + B / 256 - 32768
</code></pre>
<p>Because the grid is tile-aligned, loading elevation means fetching the tiles, drawing them onto one big canvas in their natural positions, reading the pixels back, and decoding. Nothing is resampled. The grid cell and the elevation sample are the same pixel.</p>
<pre><code class="language-javascript">function terrariumUrl(z, x, y) {
  return `https://s3.amazonaws.com/elevation-tiles-prod/terrarium/${z}/${x}/${y}.png`;
}

function loadImage(url) {
  return new Promise((resolve, reject) =&gt; {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () =&gt; resolve(img);
    img.onerror = reject;
    img.src = url;
  });
}

async function loadElevation(grid) {
  const cv = document.createElement("canvas");
  cv.width = grid.W;
  cv.height = grid.H;
  const cx = cv.getContext("2d", { willReadFrequently: true });

  const jobs = [];
  for (let iy = 0; iy &lt; grid.ny; iy++) {
    for (let ix = 0; ix &lt; grid.nx; ix++) {
      jobs.push(
        loadImage(terrariumUrl(grid.z, grid.x0 + ix, grid.y0 + iy))
          .then(img =&gt; cx.drawImage(img, ix * 256, iy * 256))
      );
    }
  }
  await Promise.all(jobs);

  const d = cx.getImageData(0, 0, grid.W, grid.H).data;
  const elev = new Float32Array(grid.W * grid.H);
  for (let i = 0; i &lt; elev.length; i++) {
    const o = i * 4;
    elev[i] = d[o] * 256 + d[o + 1] + d[o + 2] / 256 - 32768;
  }
  return elev;
}
</code></pre>
<p><code>crossOrigin = "anonymous"</code> is required. Without it the canvas becomes "tainted" and <code>getImageData</code> throws. The Terrarium bucket sends the right CORS headers, so this works as written.</p>
<p>Since you already have a Mapbox token, you can use Mapbox's own <strong>Terrain-RGB</strong> tiles instead. The URL is <code>https://api.mapbox.com/v4/mapbox.terrain-rgb/{z}/{x}/{y}.pngraw?access_token=…</code> and the decode formula is different:</p>
<pre><code class="language-javascript">// Mapbox Terrain-RGB
elev[i] = -10000 + (d[o] * 65536 + d[o + 1] * 256 + d[o + 2]) * 0.1;
</code></pre>
<p>HazardSim supports both and switches with a config flag. Terrain-RGB is cleaner data with fewer spikes. Terrarium is free of per-tile billing. For a tutorial, I'd start with Terrarium.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/e8f721b6-ff2d-44b6-9bb0-3c0830ada2ba.png" alt="Decoded elevation for the coast around Odesa rendered as a greyscale heightmap, with the sea in dark blue and higher ground lighter. Orange lines show the boundaries of the 4 by 4 Terrarium tiles that were stitched together." style="display: block;" width="600" height="400" loading="lazy">

<p>Two practical notes. Terrarium data has occasional single-pixel spikes, and a 3×3 median pass over cells that differ from all their neighbours by more than a few metres cleans them up. And cells at or below zero are sea, except where they aren't, like in the Netherlands, New Orleans, or Death Valley. I treat <code>elev &lt;= 0</code> as sea only when the cell connects to the map edge through other sea cells. That handles the common cases.</p>
<h2 id="heading-step-3-attach-a-canvas-as-a-map-source">Step 3: Attach a Canvas as a Map Source</h2>
<p>Now the central move. Create a canvas the size of your grid and register it with the map as a <code>canvas</code> source whose four corners are the snapped rectangle from Step 1.</p>
<pre><code class="language-html">&lt;div id="map"&gt;&lt;/div&gt;
&lt;canvas id="floodcanvas" width="10" height="10" style="display:none"&gt;&lt;/canvas&gt;
</code></pre>
<pre><code class="language-javascript">mapboxgl.accessToken = "YOUR_MAPBOX_TOKEN";

const map = new mapboxgl.Map({
  container: "map",
  style: "mapbox://styles/mapbox/satellite-streets-v12",
  center: [30.73, 46.48],
  zoom: 11,
});

const fc = document.getElementById("floodcanvas");
const fctx = fc.getContext("2d");
let fimg = null;

function attachCanvas(grid) {
  fc.width = grid.W;
  fc.height = grid.H;
  fimg = fctx.createImageData(grid.W, grid.H);

  const c = grid.corners;
  const coords = [[c.w, c.n], [c.e, c.n], [c.e, c.s], [c.w, c.s]];

  if (map.getSource("flood")) {
    map.getSource("flood").setCoordinates(coords);
    return;
  }
  map.addSource("flood", {
    type: "canvas",
    canvas: "floodcanvas",
    coordinates: coords,
    animate: true,
  });
  map.addLayer({
    id: "flood",
    type: "raster",
    source: "flood",
    paint: {
      "raster-opacity": 0.94,
      "raster-resampling": "linear",
      "raster-fade-duration": 0,
    },
  });
}
</code></pre>
<p>Three options matter here:</p>
<ul>
<li><p><code>animate: true</code> tells Mapbox GL to re-upload the canvas as a texture every frame. Without it, the animation freezes on the first frame.</p>
</li>
<li><p><code>"raster-resampling": "linear"</code> blends between cells when the user zooms in past your grid resolution. With 105-metre cells at a city zoom, it's the difference between water and a pixel-art game.</p>
</li>
<li><p><code>"raster-fade-duration": 0</code> turns off the cross-fade Mapbox GL applies to raster tiles. You're redrawing every frame, and a fade only smears it.</p>
</li>
</ul>
<p>The canvas is <code>display:none</code> because the map reads it directly. It never needs to be visible in the DOM.</p>
<p>From here on, anything you write into <code>fimg</code> and push with <code>fctx.putImageData(fimg, 0, 0)</code> shows up on the map in the right place, at any pitch and bearing, draped over terrain if terrain is on. The rendering problem has turned into a pixel-array problem.</p>
<h2 id="heading-step-4-simulate-a-flood-that-respects-the-terrain">Step 4: Simulate a Flood That Respects the Terrain</h2>
<p>A tsunami doesn't flood everything below the wave height. Water has to get there. It enters from the sea, flows over low ground, and stops where the ground rises above the water level, even if there's lower ground on the far side of a ridge. That's a connectivity problem, and breadth-first search is the tool for it.</p>
<p>First, a distance field: how far is each cell from the coast? Seed the queue with every sea cell adjacent to land, then expand into the sea. You'll use this to animate the wave approaching, and the land-side equivalent to animate it spreading inland.</p>
<pre><code class="language-javascript">function seaDistance(grid, sea) {
  const { W, H, cellM } = grid;
  const N = W * H;
  const dist = new Float32Array(N).fill(-1);
  const q = new Int32Array(N);
  let head = 0, tail = 0;

  // seed: sea cells that touch land
  for (let i = 0; i &lt; N; i++) {
    if (!sea[i]) continue;
    const x = i % W, y = (i / W) | 0;
    const touchesLand =
      (x &gt; 0 &amp;&amp; !sea[i - 1]) || (x &lt; W - 1 &amp;&amp; !sea[i + 1]) ||
      (y &gt; 0 &amp;&amp; !sea[i - W]) || (y &lt; H - 1 &amp;&amp; !sea[i + W]);
    if (touchesLand) { dist[i] = 0; q[tail++] = i; }
  }

  // expand
  while (head &lt; tail) {
    const c = q[head++];
    const cx = c % W, cy = (c / W) | 0;
    for (let dy = -1; dy &lt;= 1; dy++) {
      for (let dx = -1; dx &lt;= 1; dx++) {
        if (!dx &amp;&amp; !dy) continue;
        const nx = cx + dx, ny = cy + dy;
        if (nx &lt; 0 || ny &lt; 0 || nx &gt;= W || ny &gt;= H) continue;
        const n = ny * W + nx;
        if (!sea[n] || dist[n] &gt;= 0) continue;
        dist[n] = dist[c] + (dx &amp;&amp; dy ? cellM * Math.SQRT2 : cellM);
        q[tail++] = n;
      }
    }
  }
  return dist;
}
</code></pre>
<p>The flood is the same search in the other direction, with one extra rule: a cell is reachable only if the water surface is above its ground. <code>waveH</code> is the wave height at the shore in metres. The water loses height as it travels inland, and a linear decay is enough to look right:</p>
<pre><code class="language-javascript">function floodFill(grid, elev, sea, waveH) {
  const { W, H, cellM } = grid;
  const N = W * H;
  const depth = new Float32Array(N);       // metres of water above ground
  const reach = new Float32Array(N).fill(-1); // distance from shore, metres
  const q = new Int32Array(N);
  let head = 0, tail = 0;

  for (let i = 0; i &lt; N; i++) {
    if (!sea[i]) continue;
    const x = i % W, y = (i / W) | 0;
    const nb = [i - 1, i + 1, i - W, i + W];
    for (const n of nb) {
      if (n &lt; 0 || n &gt;= N || sea[n] || reach[n] &gt;= 0) continue;
      if ((n === i - 1 &amp;&amp; x === 0) || (n === i + 1 &amp;&amp; x === W - 1)) continue;
      reach[n] = 0;
      q[tail++] = n;
    }
  }

  const decayPerM = waveH / 12000; // loses its height over ~12 km

  while (head &lt; tail) {
    const c = q[head++];
    const level = waveH - reach[c] * decayPerM;
    const d = level - elev[c];
    if (d &lt;= 0) continue;               // ground is above the water here: stop
    depth[c] = d;

    const cx = c % W, cy = (c / W) | 0;
    for (let dy = -1; dy &lt;= 1; dy++) {
      for (let dx = -1; dx &lt;= 1; dx++) {
        if (!dx &amp;&amp; !dy) continue;
        const nx = cx + dx, ny = cy + dy;
        if (nx &lt; 0 || ny &lt; 0 || nx &gt;= W || ny &gt;= H) continue;
        const n = ny * W + nx;
        if (sea[n] || reach[n] &gt;= 0) continue;
        reach[n] = reach[c] + (dx &amp;&amp; dy ? cellM * Math.SQRT2 : cellM);
        q[tail++] = n;
      }
    }
  }
  return { depth, reach };
}
</code></pre>
<p>Run this once per scenario, not per frame. On a 700×500 grid it takes a few milliseconds. The outputs, <code>depth</code> for every cell and <code>reach</code> for how far inland it is, are all the animation needs.</p>
<p>Because the fill follows real elevation, it goes up river valleys and stops at embankments. Multiply the flooded cell count by <code>cellM²</code> and you get a flooded area in square kilometres you can stand behind.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/171566ef-f912-4a90-894f-ae4adda8e252.jpg" alt="Top-down view of Odesa after the simulated tsunami. Dark brown water covers the low coastal districts and the port, following the shape of the coastline and stopping at higher ground inland. The open sea stays its normal dark blue." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-5-animate-by-writing-pixels">Step 5: Animate by Writing Pixels</h2>
<p>The frame loop does one thing. For each flooded land cell, it decides whether the water has arrived yet, and if so, what colour it is.</p>
<p>"Arrived" is a comparison of the cell's <code>reach</code> against a front position <code>F</code> that grows with time. Add some per-cell noise to <code>reach</code> so the front isn't a perfect contour line, and the water starts to look like water:</p>
<pre><code class="language-javascript">let t0 = null;
const T_LANDFALL = 3.9;   // seconds until the wave reaches the shore
const T_FULL = 10.9;      // seconds until it reaches its maximum extent

function ease(k) {
  return k &lt;= 0 ? 0 : k &gt;= 1 ? 1 : 1 - Math.pow(1 - k, 2.2);
}

function frame(now) {
  if (t0 === null) t0 = now;
  const t = (now - t0) / 1000;
  const d = fimg.data;
  const { W, H } = grid;

  // how far inland the front has got, in metres
  const F = t &lt; T_LANDFALL
    ? -1e9
    : maxReach * ease((t - T_LANDFALL) / (T_FULL - T_LANDFALL));

  for (let i = 0; i &lt; W * H; i++) {
    const o = i * 4;
    if (sea[i] || depth[i] &lt;= 0) { d[o + 3] = 0; continue; }

    const arrival = reach[i] + noise[i] * 180;   // ±90 m of jitter
    if (arrival &gt; F) { d[o + 3] = 0; continue; }

    const age = (F - arrival) / 150;             // how long the cell has been under
    const dp = depth[i];

    // base colour: shallow is lighter, deep is darker
    let r = 40 + Math.max(0, 60 - dp * 10);
    let g = 70 + Math.max(0, 50 - dp * 8);
    let b = 95 + Math.max(0, 40 - dp * 6);
    let a = dp &lt; 0.5 ? 215 + dp * 60 : 245;

    // the leading edge is foam: push toward white for the first moments
    if (age &lt; 1.4) {
      const k = Math.pow(1 - age / 1.4, 2) * 0.8;
      r += (240 - r) * k; g += (246 - g) * k; b += (246 - b) * k;
    }

    d[o] = r; d[o + 1] = g; d[o + 2] = b; d[o + 3] = a;
  }

  fctx.putImageData(fimg, 0, 0);
  if (t &lt; T_FULL + 3) requestAnimationFrame(frame);
}

requestAnimationFrame(frame);
</code></pre>
<p><code>noise</code> is a <code>Float32Array</code> of random values in <code>[-0.5, 0.5]</code> generated once per run. <code>maxReach</code> is the largest <code>reach</code> value among flooded cells.</p>
<p>There are two performance points: A 700×500 grid is 350,000 cells, and this loop runs at 60 fps on my laptop and around 30 fps on a mid-range phone. If you need more, iterate only over the list of flooded land cells (collect the indices once after <code>floodFill</code>) instead of the whole grid. In a coastal city, that's usually a quarter of the cells. And keep <code>fimg</code> between frames. Calling <code>createImageData</code> every frame is the first thing a profiler will point at.</p>
<p>If you stop here, you have a working, terrain-aware simulation. The rest is about making it look like footage instead of a diagram.</p>
<h2 id="heading-step-6-layer-video-textures-with-canvas-compositing">Step 6: Layer Video Textures With Canvas Compositing</h2>
<p>Flat colour reads as a diagram. Real water has texture that moves. The cheapest way to get that is to drape a looping video of real water over the flooded area, and the restriction "over the flooded area only" comes free from two canvas compositing modes:</p>
<ul>
<li><p><code>source-atop</code> draws the new image only where the destination already has pixels. Draw a video onto the flood canvas with <code>source-atop</code> and it appears on water only. Dry land stays untouched, because dry land is transparent.</p>
</li>
<li><p><code>destination-in</code> keeps the destination only where the new image has pixels. Draw a black-and-transparent mask onto a buffer with <code>destination-in</code>, and the buffer is cropped to the mask's shape.</p>
</li>
</ul>
<p>You need a hidden <code>&lt;video&gt;</code> (muted, looping, and <code>playsInline</code>. You need all three, or mobile Safari refuses to autoplay) and one scratch canvas the size of the grid:</p>
<pre><code class="language-javascript">const vid = document.createElement("video");
vid.muted = true; vid.loop = true; vid.playsInline = true;
vid.setAttribute("muted", ""); vid.setAttribute("playsinline", "");
vid.src = "/fx/water.webm";
vid.style.cssText = "position:absolute;width:1px;height:1px;opacity:0;pointer-events:none";
document.body.appendChild(vid);
vid.load();

const buf = document.createElement("canvas");
buf.width = grid.W; buf.height = grid.H;
const bctx = buf.getContext("2d");

function drawWaterTexture(mud) {
  if (vid.readyState &lt; 2) return;         // not enough data yet: skip, don't block
  if (vid.paused) vid.play().catch(() =&gt; {});

  // 1. video into the scratch buffer, stretched to the grid
  bctx.globalCompositeOperation = "source-over";
  bctx.clearRect(0, 0, grid.W, grid.H);
  bctx.drawImage(vid, 0, 0, grid.W, grid.H);

  // 2. tint it, only where the video is (everywhere, at this point)
  bctx.globalCompositeOperation = "source-atop";
  bctx.globalAlpha = 0.5 + 0.38 * mud;
  bctx.fillStyle = `rgb(${28 + 108 * mud | 0}, ${112 - 10 * mud | 0}, ${178 - 126 * mud | 0})`;
  bctx.fillRect(0, 0, grid.W, grid.H);
  bctx.globalAlpha = 1;

  // 3. crop the buffer to land cells, so the open sea keeps its own colour
  bctx.globalCompositeOperation = "destination-in";
  bctx.drawImage(landMask, 0, 0);

  // 4. onto the flood canvas, only where there's already water
  fctx.save();
  fctx.globalCompositeOperation = "source-atop";
  fctx.globalAlpha = 0.8;
  fctx.drawImage(buf, 0, 0);
  fctx.restore();
}
</code></pre>
<p>Call this right after <code>fctx.putImageData(fimg, 0, 0)</code> in the frame loop. Step 4 is where it pays off. Because the flood canvas is transparent wherever there's no water, <code>source-atop</code> puts the texture exactly inside the flood outline, following every bay and valley the BFS found, with no per-pixel work on your side.</p>
<p><code>landMask</code> is a canvas you build once per run: white where <code>sea[i]</code> is false, transparent elsewhere. Without it the texture also covers the open sea, and the whole gulf turns the colour of the flood. I shipped that version for about an hour before I noticed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/926abb5b-ba8a-4cf9-8eb9-2e343ad53313.jpg" alt="Pitched view from above the shore of Odesa, looking out to sea. A white foam front sweeps across the coast and into the city, following the coastline, with darker water behind it." style="display: block;" width="600" height="400" loading="lazy">

<p>The <code>mud</code> parameter is a time ramp from 0 to 1. Tsunami water arrives as seawater and turns dark brown as it picks up sediment off the land. Interpolating the tint from sea blue to mud brown over a few seconds after landfall gives you that for free.</p>
<p>The first time I built this, I had baked a brown grade into the video file itself, and no amount of tinting could make the water blue at the start. Keep the source footage neutral and do colour in code.</p>
<p>The same <code>destination-in</code> trick gives you a depth-driven darkening (a mask whose alpha is proportional to <code>depth[i]</code>, filled with a dark colour) and a foam band along the moving front (a mask whose alpha is a Gaussian of <code>reach[i] - F</code>, filled with a second video of churning foam). Each is a few lines, and each one makes the water read a little more as a volume and a little less as a tint.</p>
<h2 id="heading-step-7-move-the-camera-into-the-scene">Step 7: Move the Camera Into the Scene</h2>
<p>A top-down map is an illustration. Tilt the camera and the same pixels become a place. The flood canvas is draped on the ground, so it foreshortens correctly. Your only job is to point the camera somewhere worth looking at.</p>
<p>For a tsunami, that means standing on the shore near the city, looking out to sea, and watching it come in. "Near the city" is the coastline cell closest to the map centre. "Out to sea" is the average direction to nearby sea cells from that point. Compute it locally rather than for the whole coastline, or a curved bay will point your camera at the wrong horizon. I found that out with Odesa, where the citywide average pointed the camera at empty fields next to the airport.</p>
<pre><code class="language-javascript">function shoreCamera(grid, sea, center) {
  const { W, H, z, x0, y0, cellM } = grid;

  // 1. nearest coastline cell to the map centre
  let best = -1, bestD = Infinity;
  for (let i = 0; i &lt; W * H; i++) {
    if (!sea[i]) continue;
    const x = i % W, y = (i / W) | 0;
    const coast = (x &gt; 0 &amp;&amp; !sea[i - 1]) || (x &lt; W - 1 &amp;&amp; !sea[i + 1]) ||
                  (y &gt; 0 &amp;&amp; !sea[i - W]) || (y &lt; H - 1 &amp;&amp; !sea[i + W]);
    if (!coast) continue;
    const lng = x2lon(x0 + (x + 0.5) / 256, z), lat = y2lat(y0 + (y + 0.5) / 256, z);
    const d = (lng - center.lng) ** 2 + (lat - center.lat) ** 2;
    if (d &lt; bestD) { bestD = d; best = i; }
  }
  const sx = best % W, sy = (best / W) | 0;

  // 2. local seaward direction: average offset to sea cells within ~2.5 km
  const r = Math.max(6, Math.round(2500 / cellM));
  let vx = 0, vy = 0;
  for (let dy = -r; dy &lt;= r; dy++) {
    for (let dx = -r; dx &lt;= r; dx++) {
      const x = sx + dx, y = sy + dy;
      if (x &lt; 0 || y &lt; 0 || x &gt;= W || y &gt;= H || !sea[y * W + x]) continue;
      const len = Math.hypot(dx, dy);
      if (len &lt; 1 || len &gt; r) continue;
      vx += dx / len; vy += dy / len;
    }
  }
  const len = Math.hypot(vx, vy) || 1;
  const east = vx / len, north = -vy / len;   // grid y grows southward

  const shoreLng = x2lon(x0 + (sx + 0.5) / 256, z);
  const shoreLat = y2lat(y0 + (sy + 0.5) / 256, z);
  const mLng = 111320 * Math.cos(shoreLat * Math.PI / 180), mLat = 111320;

  return {
    // stand 900 m inland of the shore...
    center: [shoreLng - east * 900 / mLng, shoreLat - north * 900 / mLat],
    // ...facing the sea
    bearing: Math.atan2(east, north) * 180 / Math.PI,
  };
}
</code></pre>
<p>Then it's two <code>easeTo</code> calls: dive in before landfall, pull back out before the results appear.</p>
<pre><code class="language-javascript">const before = {
  center: map.getCenter(), zoom: map.getZoom(),
  pitch: map.getPitch(), bearing: map.getBearing(),
};
const cam = shoreCamera(grid, sea, map.getCenter());

map.easeTo({
  center: cam.center,
  zoom: Math.min(13.6, before.zoom + 2.8),
  pitch: 72,
  bearing: cam.bearing,
  duration: 1200,
});

setTimeout(() =&gt; map.easeTo({ ...before, duration: 2600 }), (T_LANDFALL + 3.2) * 1000);
</code></pre>
<p>Here is what those two calls produce. The camera has dropped to a steep pitch above the shore of Odesa and turned to face the Gulf. The sea pulls back first, exposing a wide band of sand, and then the front comes in over the beach and into the port. Everything you see moving is the same flood canvas from Step 3, foreshortened by the camera and draped over the terrain.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/4ba27056-3ff4-4d5a-9472-f0d43bb101b6.gif" alt="Animated: a tilted camera view over the shore of Odesa. The sea first draws back from the beach, exposing sand, then a dark wave front rolls back in across the beach and the port while the city fills the lower half of the frame." style="display: block;" width="600" height="400" loading="lazy">

<p>Notice that nothing in the frame loop knows or cares that the camera moved. The loop keeps writing the same pixels into the same canvas, and the map does the rest. That's what Step 3 bought you.</p>
<h2 id="heading-where-the-ground-layer-stops">Where the Ground Layer Stops</h2>
<p>Everything so far lives on the ground. The flood canvas is the map's own surface, and that's exactly why it works: perspective, rotation and terrain come free. The moment you need something with height, the trick runs out. You can't drape a tower onto a raster.</p>
<p>I learned where that line is the expensive way. I built a real 3D wall of water as a <code>three.js</code> custom layer, and it worked. I also cut it, for a reason worth knowing before you spend the time.</p>
<p>A 30-metre wave seen from 250 metres away covers about 7 degrees of a 37-degree field of view, a fifth of the screen. To make it fill the frame, you have to put the camera a hundred metres from it and nearly on the ground, where satellite imagery smears into mud. The geometry was correct and the shot was bad. Draped water under a pitched camera turned out to read as more water than a correctly-scaled wall did.</p>
<p>So the ground layer stayed a ground layer. But the earthquake scenario had no such escape: a building that collapses has to be an object. That is Part 2.</p>
<h2 id="heading-part-2-things-with-height">Part 2: Things with Height</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/ee831f6b-7f05-4b1b-ac4b-f47152fad5d5.jpg" alt="Street-level view of central Kyiv during a simulated magnitude 7.8 earthquake. Textured high-rise towers stand in the foreground, several of them leaning, and grey dust clouds drift across the scene over the Olympic stadium." style="display: block;" width="600" height="400" loading="lazy">

<p>For the earthquake scenario, I needed buildings to fall. This half is what I ended up with after roughly ten rounds of rebuilding it: a mix of map-native extrusions for the thousands of buildings that only need to stand there, and a <code>three.js</code> scene rendered into the map's own WebGL context for the ones that come down.</p>
<p>Both paths read the same vector tiles, share the same depth buffer and move together under every camera move.</p>
<h2 id="heading-two-kinds-of-3d-and-why-you-need-both">Two Kinds of 3D, and Why You Need Both</h2>
<p>A <code>fill-extrusion</code> layer is the map engine's own 3D. It's fast, it handles tens of thousands of buildings, it's lit consistently with the rest of the map, and it's driven by data expressions: height from a property, colour from a property, and, the part that matters here, height multiplied by a per-feature <em>state</em> you can change at runtime. What it can't do is move. An extrusion is always a vertical prism standing on the ground.</p>
<p>A <code>custom</code> layer gives you the map's WebGL context and projection matrix every frame and lets you draw whatever you want into it. With <code>three.js</code> on the other end, that means real objects: meshes with position, rotation, and scale, instanced by the thousand, writing into the same depth buffer as the map so that a falling block disappears correctly behind a building that's still standing.</p>
<p>The split that works is simple. <strong>Every building is an extrusion.</strong> When one is damaged, its extrusion shrinks (that's the per-feature state). When one collapses close enough to the camera that you'd see the difference, the extrusion shrinks to nothing and a <code>three.js</code> object takes its place for the two seconds of the fall. Then the object is replaced with a pile of rubble, also <code>three.js</code>, and the frame rate never notices.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/de9747f7-1e42-4923-862b-eb8b2a7d3708.jpg" alt="Pitched view of central Kyiv before the shaking starts. Thousands of buildings are extruded from the map's vector tiles with window-pattern textures on their walls; a 3D model of the Olympic stadium stands in the middle distance." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-8-get-buildings-out-of-the-maps-own-tiles">Step 8: Get Buildings Out of the Map's Own Tiles</h2>
<p>You don't need a building dataset. The map is already downloading one. Mapbox Streets vector tiles carry a <code>building</code> source layer with a polygon footprint, a <code>height</code>, a <code>min_height</code> for buildings that start above ground, and an <code>extrude</code> flag. <code>querySourceFeatures</code> reads whatever is currently loaded:</p>
<pre><code class="language-javascript">function buildingSource(map) {
  const sources = map.getStyle().sources;
  if (sources.composite) return "composite";         // Mapbox styles
  if (sources.openmaptiles) return "openmaptiles";   // MapLibre + OpenMapTiles
  for (const id in sources) if (sources[id].type === "vector") return id;
  return null;
}

function collectBuildings(map, center, kx, ky) {
  const src = buildingSource(map);
  if (!src) return [];
  const feats = map.querySourceFeatures(src, { sourceLayer: "building" });
  const out = [], seen = new Set();

  for (const f of feats) {
    const p = f.properties || {}, g = f.geometry;
    if (!g || p.extrude === "false" || p.underground === "true") continue;

    const polys = g.type === "Polygon" ? [g.coordinates]
                : g.type === "MultiPolygon" ? g.coordinates : null;
    if (!polys || !polys[0] || polys[0][0].length &lt; 4) continue;

    // centroid and bounds of the outer ring
    const ring = polys[0][0], n = ring.length - 1;
    let sx = 0, sy = 0, x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
    for (let j = 0; j &lt; n; j++) {
      const [x, y] = ring[j];
      sx += x; sy += y;
      if (x &lt; x0) x0 = x; if (x &gt; x1) x1 = x;
      if (y &lt; y0) y0 = y; if (y &gt; y1) y1 = y;
    }
    sx /= n; sy /= n;

    // the same building appears once per tile it touches
    const key = sx.toFixed(6) + "," + sy.toFixed(6) + "," + n;
    if (seen.has(key)) continue;
    seen.add(key);

    let h = +(p.height ?? p.render_height);
    if (!(h &gt; 0)) h = 8;                              // no data: assume two storeys
    const mh = Math.min(h, +(p.min_height ?? p.render_min_height) || 0);

    out.push({
      geometry: g, polys, lng: sx, lat: sy, h, mh,
      d: Math.hypot((sx - center.lng) * kx, (sy - center.lat) * ky),   // metres from epicentre
      rf: 0.5 * Math.sqrt(Math.max(4, (x1 - x0) * kx * (y1 - y0) * ky)), // rough radius, metres
    });
  }
  out.sort((a, b) =&gt; a.d - b.d);
  return out.slice(0, 9000);
}
</code></pre>
<p><code>kx</code> and <code>ky</code> are metres per degree of longitude and latitude at the city's latitude: <code>111320 * cos(lat)</code> and <code>110574</code>. Everything downstream works in metres from the epicentre.</p>
<p>Three things to know about this data. First, <code>querySourceFeatures</code> returns a building once for each tile it intersects, so you have to deduplicate. A rounded centroid plus vertex count makes a good key.</p>
<p>Second, the features <strong>have no stable IDs</strong> in Mapbox Streets. That matters enormously in Step 11.</p>
<p>Third, you only get what's loaded, which means you have to zoom in to where the buildings are before you collect them, and wait for the tiles to arrive.</p>
<p>In HazardSim, the camera dives in two steps, holds until <code>map.areTilesLoaded()</code> is true and the feature count stops changing between polls, and only then runs <code>collectBuildings</code>. If you collect too early, you get the ten buildings that happened to be in the first tile.</p>
<h2 id="heading-step-9-make-extrusions-look-like-buildings">Step 9: Make Extrusions Look Like Buildings</h2>
<p>The default extrusion is a flat-shaded box in one colour. It reads as a diagram. The trick that fixes it costs almost nothing: <code>fill-extrusion-pattern</code> takes an image from the style's sprite and tiles it over the walls, and you can add images to the sprite at runtime with <code>map.addImage</code>.</p>
<p>Draw a few façade textures on a canvas (rows of windows on a wall colour, with slight noise so they don't look computer-generated), register them, and pick one per building from its height:</p>
<pre><code class="language-javascript">function makeFacade(map, id, wall, glass) {
  const cv = document.createElement("canvas");
  cv.width = 128; cv.height = 64;
  const c = cv.getContext("2d");
  c.fillStyle = wall; c.fillRect(0, 0, 128, 64);

  // four floors of windows, 16px per floor
  for (let f = 0; f &lt; 4; f++) {
    for (let x = 3; x + 6 &lt;= 128; x += 12) {
      const lit = Math.random() &lt; 0.07;
      c.fillStyle = lit ? "#c6b68c" : glass;
      c.fillRect(x, f * 16 + 3, 6, 9);
    }
  }
  // film grain
  const im = c.getImageData(0, 0, 128, 64), d = im.data;
  for (let i = 0; i &lt; d.length; i += 4) {
    const n = (Math.random() - 0.5) * 12;
    d[i] += n; d[i + 1] += n; d[i + 2] += n;
  }
  map.addImage(id, im);
}

makeFacade(map, "fac-glass",  "#566a78", "#3c4e5c");
makeFacade(map, "fac-light",  "#cdc8be", "#364048");
makeFacade(map, "fac-brick",  "#8a5a44", "#2e2a26");
</code></pre>
<p>Then the layer. Height and base are expressions, and both are multiplied by a feature-state value <code>k</code> that defaults to 1. That multiplier is the whole collapse mechanism, so it goes in now:</p>
<pre><code class="language-javascript">const H  = ["coalesce", ["get", "height"], ["get", "render_height"], 8];
const MH = ["coalesce", ["get", "min_height"], ["get", "render_min_height"], 0];
const K  = ["coalesce", ["feature-state", "k"], 1];

function addBuildingLayers(map, source, sourceLayer, before) {
  const common = { type: "fill-extrusion", source, minzoom: 13 };
  if (sourceLayer) {
    common["source-layer"] = sourceLayer;
    common.filter = ["all", ["!=", ["get", "extrude"], "false"], ["!=", ["get", "underground"], "true"]];
  }
  map.addLayer({
    ...common, id: "bld-walls",
    paint: {
      "fill-extrusion-pattern": ["case",
        ["&gt;=", H, 45], "fac-glass",
        ["&gt;=", H, 14], "fac-light",
        "fac-brick"],
      "fill-extrusion-height": ["*", H, K],
      "fill-extrusion-base":   ["*", MH, K],
      "fill-extrusion-opacity": 1,
    },
  }, before);
  // a thin second extrusion on top gives the roof its own texture
  map.addLayer({
    ...common, id: "bld-roof",
    paint: {
      "fill-extrusion-pattern": "fac-roof",
      "fill-extrusion-height": ["+", ["*", H, K], 0.05],
      "fill-extrusion-base":   ["max", ["*", MH, K], ["-", ["*", H, K], 0.45]],
      "fill-extrusion-opacity": 1,
    },
  }, before);
}
</code></pre>
<p><code>before</code> should be the ID of the first <code>symbol</code> layer in the style, so the buildings go under the labels. Find it with a loop over <code>map.getStyle().layers</code>.</p>
<p>A pattern can't be rotated or scaled per feature, so windows don't line up with floors exactly. At the camera heights this runs at, nobody can tell.</p>
<h2 id="heading-step-10-decide-what-falls">Step 10: Decide What Falls</h2>
<p>Now the simulation part, which is short. For each building, you need the shaking intensity at its location and the probability that a building of that type collapses at that intensity.</p>
<p>Shaking is expressed on the Modified Mercalli scale, I to XII. A standard attenuation relation gives intensity from magnitude <code>M</code> and distance <code>r</code> in kilometres (<code>depth</code> is the hypocentre depth, 10 km for a shallow crustal quake):</p>
<pre><code class="language-javascript">const DEPTH_KM = 10;

function mmiAt(M, rKm) {
  const R = Math.sqrt(rKm * rKm + DEPTH_KM * DEPTH_KM);
  const v = 1.5 * M - 3.0 * Math.log10(R) - 0.0018 * R + 1.6;
  return Math.max(0, Math.min(12, v));
}
</code></pre>
<p>Collapse probability as a function of intensity is a fragility curve. The published ones are per building class and far more detailed than a simulation needs. This piecewise-linear version is tuned to look right and to give sane casualty numbers. Multiply it by a factor for construction quality and another for height. Mid-rise buildings fare worst, very tall ones are engineered and fare better:</p>
<pre><code class="language-javascript">const FRAG = [[6.5, 0], [7, 0.004], [8, 0.025], [9, 0.08], [10, 0.2], [11, 0.38], [12, 0.55]];
const QUALITY = { strict: 0.2, avg: 1, poor: 3 };

function collapseProbability(mmi) {
  if (mmi &lt;= FRAG[0][0]) return 0;
  for (let i = 1; i &lt; FRAG.length; i++) {
    if (mmi &lt;= FRAG[i][0]) {
      const [m0, p0] = FRAG[i - 1], [m1, p1] = FRAG[i];
      return p0 + (p1 - p0) * (mmi - m0) / (m1 - m0);
    }
  }
  return FRAG[FRAG.length - 1][1];
}

function assignDamage(buildings, M, epicentre, quality) {
  const kx = 111320 * Math.cos(epicentre.lat * Math.PI / 180), ky = 110574;
  const q = QUALITY[quality] || 1;

  for (const b of buildings) {
    const rKm = Math.hypot((b.lng - epicentre.lng) * kx, (b.lat - epicentre.lat) * ky) / 1000;
    const mmi = mmiAt(M, rKm);
    const hf = b.h &lt; 5 ? 0.7 : b.h &lt; 35 ? 1.15 : b.h &lt; 70 ? 0.8 : 0.45;
    const pCollapse = Math.min(0.9, collapseProbability(mmi) * q * hf);
    const pDamage = mmi &gt;= 6 ? Math.min(0.6, pCollapse * 2.5 + 0.004) : 0;

    const r = Math.random();
    b.cat = r &lt; pCollapse ? 2 : r &lt; pCollapse + pDamage ? 1 : 0;   // 2 collapse, 1 damaged, 0 fine
    b.start = 0.8 + Math.random() * 2.8;                            // seconds after the shock
  }
}
</code></pre>
<p>The per-building randomness is what sells it. Two identical towers next to each other, one falls and one stands. That's also how real earthquakes look.</p>
<h2 id="heading-step-11-animate-collapse-without-rebuilding-layers">Step 11: Animate Collapse Without Rebuilding Layers</h2>
<p>Here's the mechanism from Step 9 paying off. Each frame, for every building that's falling, set its feature-state <code>k</code> to a number between 1 and its final rubble height. The extrusion shrinks. No layer is rebuilt, no GeoJSON is re-set, and no tiles are re-parsed.</p>
<pre><code class="language-javascript">map.setFeatureState({ source: "bld", id: b.id }, { k: 0.62 });
</code></pre>
<p>There's one catch, and it's the one that cost me the most time in this whole project: <code>setFeatureState</code> <strong>needs a feature ID, and Mapbox Streets buildings don't have one.</strong> Calling it against the vector source silently does nothing.</p>
<p>The fix is to copy the buildings you collected into your own GeoJSON source, with an <code>id</code> you control, and tell the source to promote that property to the feature ID:</p>
<pre><code class="language-javascript">const feats = buildings.map((b, i) =&gt; {
  b.id = i;
  return { type: "Feature", properties: { i, height: b.h, min_height: b.mh }, geometry: b.geometry };
});

map.addSource("bld", {
  type: "geojson",
  data: { type: "FeatureCollection", features: feats },
  promoteId: "i",
  maxzoom: 16,
});
addBuildingLayers(map, "bld", null, firstSymbolLayer);
</code></pre>
<p>Now <code>setFeatureState({ source: "bld", id: 17 }, ...)</code> reaches building 17. Keep the original tile-backed layers around until the GeoJSON source reports <code>isSourceLoaded</code>, then remove them, so there's no frame where the city blinks.</p>
<p>The shape of <code>k</code> over time is what makes a collapse look like a collapse rather than a building sliding into the ground. I ended up with five modes, chosen at random per building and constrained by height:</p>
<pre><code class="language-javascript">// k0 = starting height multiplier (1), kf = final rubble height, u = progress 0..1
function collapseK(b, u) {
  const k0 = b.k0, sp = k0 - b.kf;
  switch (b.mode) {
    case 1: {   // floor by floor: ns steps, each with a fast drop then a pause
      const s = u * b.ns, i = Math.min(b.ns - 1, Math.floor(s));
      let f = s - i;
      f = f &lt; 0.45 ? f * f / 0.45 * 1.1 : 0.55 + (f - 0.45) * 0.45 / 0.55;
      return k0 - sp * Math.min(1, (i + Math.min(1, f)) / b.ns);
    }
    case 2: {   // soft storey: ground floor gives, building drops one floor, pauses, then goes
      const sd = Math.min(sp * 0.5, 3.2 / b.h);
      if (u &lt; 0.12) return k0 - sd * (u / 0.12) ** 2;
      if (u &lt; 0.5)  return k0 - sd;
      const v = (u - 0.5) / 0.5;
      return k0 - sd - (sp - sd) * v * v;
    }
    case 3: {   // crushed top: upper part fails first
      const pt = sp * 0.45;
      if (u &lt; 0.3)  return k0 - pt * (u / 0.3) ** 2;
      if (u &lt; 0.55) return k0 - pt;
      const v = (u - 0.55) / 0.45;
      return k0 - pt - (sp - pt) * v * v;
    }
    case 4:     // sideways: steady, slightly accelerating
      return k0 - sp * Math.pow(u, 1.6);
    default:    // pancake: accelerating free fall
      return k0 - sp * u * u;
  }
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/090f37e2-11d6-41cd-ae23-0affa8357364.png" alt="Line chart of remaining height against progress for the five collapse modes. Pancake is a smooth accelerating curve. Floor-by-floor is a staircase of drops and pauses. Soft storey drops a little, holds flat for a long time, then falls. Crushed top drops early, holds, then falls. Sideways is a gentle curve. All five meet at the same final height." style="display: block;" width="600" height="400" loading="lazy">

<p>The frame loop is then a few lines per building:</p>
<pre><code class="language-javascript">function frame(now) {
  const el = (now - T0) / 1000, dt = Math.min(0.04, (now - last) / 1000);
  last = now;

  for (const b of active) {
    if (b.done || el &lt; b.start) continue;
    const u = Math.min(1, (el - b.start) / b.dur);
    const k = collapseK(b, u);
    map.setFeatureState({ source: "bld", id: b.id }, { k });
    if (u &gt;= 1) { b.done = true; onLanded(b); }
  }
  requestAnimationFrame(frame);
}
</code></pre>
<p>For a damaged-but-standing building (<code>cat === 1</code>), <code>kf</code> is close to 1 and the mode is a short settle: the building visibly drops half a metre and stops. It's a small detail, and it's the one that makes the whole street look hit rather than just the buildings that fell.</p>
<p>Clamp <code>dt</code> to 40 ms. If the tab stutters and a frame takes a quarter of a second, a building that was mid-fall would jump instead of fall, and the eye catches that instantly.</p>
<h2 id="heading-step-12-a-threejs-scene-inside-the-map">Step 12: A Three.js Scene Inside the Map</h2>
<p>Shrinking extrusions handle the buildings in the distance. For the ones near the camera, you want to see the structure break: a tower tipping over and snapping into blocks, debris arcing out from the base, or rubble left on the street. That needs objects, so this is where <code>three.js</code> comes in.</p>
<p>The custom layer gets two callbacks: <code>onAdd</code> once, with the map and its GL context, and <code>render</code> every frame, with the context and the map's projection matrix. The non-obvious part is coordinates. You want to work in metres from an origin, not in Mercator units, so build a matrix that converts, and multiply it into the map's projection each frame:</p>
<pre><code class="language-javascript">function makeScene(map, origin, before) {
  const mc = mapboxgl.MercatorCoordinate.fromLngLat([origin.lng, origin.lat], 0);
  const s = mc.meterInMercatorCoordinateUnits();
  // metres east / north / up at the origin  -&gt;  Mercator.  Mercator y grows southward, hence -s.
  const toMercator = new THREE.Matrix4()
    .makeTranslation(mc.x, mc.y, mc.z)
    .scale(new THREE.Vector3(s, -s, s));

  const scene = new THREE.Scene();
  const camera = new THREE.Camera();
  scene.add(new THREE.HemisphereLight(0xfff4e4, 0x3c342d, 0.55));
  scene.add(new THREE.AmbientLight(0xffffff, 0.1));
  const sun = new THREE.DirectionalLight(0xfff8ee, 0.8);
  sun.position.set(-0.45, -0.6, 1);
  scene.add(sun);

  let renderer = null;
  const layer = {
    id: "debris3d",
    type: "custom",
    renderingMode: "3d",
    onAdd(m, gl) {
      renderer = new THREE.WebGLRenderer({ canvas: m.getCanvas(), context: gl, antialias: true });
      renderer.autoClear = false;             // the map has already drawn this frame
      renderer.localClippingEnabled = true;   // used to sink buildings below ground
    },
    render(gl, matrix) {
      camera.projectionMatrix.fromArray(matrix).multiply(toMercator);
      renderer.resetState();                  // Mapbox changed GL state since our last draw
      renderer.render(scene, camera);
    },
  };
  map.addLayer(layer, before);
  return { scene, camera, layer };
}
</code></pre>
<p>On MapLibre, the second argument of <code>render</code> is an object, and the matrix is at <code>args.defaultProjectionData.mainMatrix</code>. Everything else is the same.</p>
<p><code>renderingMode: "3d"</code> is what makes the map share its depth buffer with you. A block of concrete that lands behind a standing extrusion is hidden by it, which is the entire reason to render into the map's context rather than in a separate canvas on top.</p>
<p>Thousands of debris pieces means instancing. HazardSim keeps a pool of <code>InstancedMesh</code> objects, one per kind of piece (slabs, wall fragments, rebar, rocks, roof prisms), each with a fixed capacity, and hands out instance indices as pieces are created:</p>
<pre><code class="language-javascript">function makePool(scene, geometry, material, capacity) {
  const mesh = new THREE.InstancedMesh(geometry, material, capacity);
  // allocate the colour buffer for the full capacity up front (see Gotchas)
  mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3).fill(1), 3);
  mesh.count = 0;
  mesh.frustumCulled = false;                 // the map camera is not a three.js camera
  mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
  scene.add(mesh);

  const dummy = new THREE.Object3D(), color = new THREE.Color();
  dummy.rotation.order = "ZXY";
  return {
    n: 0, capacity,
    add(p) {
      if (this.n &gt;= capacity) return -1;
      const i = this.n++;
      this.set(i, p);
      color.setRGB(p.rgb[0], p.rgb[1], p.rgb[2]);
      mesh.setColorAt(i, color);
      mesh.instanceColor.needsUpdate = true;
      mesh.count = this.n;
      return i;
    },
    set(i, p) {
      dummy.position.set(p.x, p.y, p.z);
      dummy.rotation.set(p.tx, p.ty, p.yaw);
      dummy.scale.set(p.sx, p.sy, p.sz);
      dummy.updateMatrix();
      mesh.setMatrixAt(i, dummy.matrix);
      mesh.instanceMatrix.needsUpdate = true;
    },
  };
}

const concrete = new THREE.MeshStandardMaterial({ map: concreteTexture, roughness: 1 });
const pools = {
  slab: makePool(scene, new THREE.BoxGeometry(1, 1, 1), concrete, 30000),
  wall: makePool(scene, new THREE.BoxGeometry(1, 1, 1), facadeMaterial, 1500),
  bar:  makePool(scene, new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0x6e4c38, metalness: 0.25 }), 20000),
  rock: makePool(scene, rockGeometry(), concrete, 9000),
};
</code></pre>
<p>Thirty thousand slabs sounds like a lot, but it's one draw call.</p>
<p>One more thing the map needs from you: it only redraws when something changes. Your scene changing is not, from the map's point of view, something changing. Call <code>map.triggerRepaint()</code> at the end of every frame in which you moved anything, or your meshes will update in memory and never on screen.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/8481687c-e9b9-48f7-846b-0520afbda6ed.jpg" alt="Pitched close-up of a Kyiv street. Several textured high-rise towers rebuilt as three.js models stand among the extruded buildings, ready for the shaking to start; the Olympic stadium model is in the background." style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-13-from-footprint-to-a-box-that-tips-over">Step 13: From Footprint to a Box That Tips Over</h2>
<p>The vector tile gives you a footprint polygon. To tip a building over, you need a box: a centre, an orientation, and two side lengths. The oriented bounding box is a few lines:</p>
<pre><code class="language-javascript">function orientedBox(ring, kx, ky, origin) {
  // ring in lng/lat -&gt; local metres
  const loc = ring.map(([lng, lat]) =&gt; [(lng - origin.lng) * kx, (lat - origin.lat) * ky]);

  // the longest edge sets the orientation
  let ang = 0, best = 0;
  for (let i = 1; i &lt; loc.length; i++) {
    const ex = loc[i][0] - loc[i - 1][0], ey = loc[i][1] - loc[i - 1][1];
    if (ex * ex + ey * ey &gt; best) { best = ex * ex + ey * ey; ang = Math.atan2(ey, ex); }
  }

  // project every vertex onto that axis and its normal, take the extents
  const ca = Math.cos(ang), sa = Math.sin(ang);
  let u0 = Infinity, u1 = -Infinity, v0 = Infinity, v1 = -Infinity;
  for (const [x, y] of loc) {
    const u = x * ca + y * sa, v = -x * sa + y * ca;
    if (u &lt; u0) u0 = u; if (u &gt; u1) u1 = u;
    if (v &lt; v0) v0 = v; if (v &gt; v1) v1 = v;
  }
  const cu = (u0 + u1) / 2, cv = (v0 + v1) / 2;
  return {
    x: cu * ca - cv * sa, y: cu * sa + cv * ca,    // centre, metres from origin
    ang,
    L1: Math.max(2, u1 - u0), L2: Math.max(2, v1 - v0),
  };
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/28644ba8-6900-4885-98f6-ca163d50aa6c.png" alt="Three-panel diagram. First: a building footprint polygon with its longest edge highlighted, labelled &quot;longest edge gives the angle&quot;. Second: the polygon rotated to that angle with an orange bounding rectangle around it, labelled &quot;L1 by L2, centre, angle&quot;. Third: a stack of four blocks hinged at the bottom edge, with the formula theta double-prime equals 1.5 g over h times sine theta." style="display: block;" width="600" height="400" loading="lazy">

<p>The box becomes a stack of two to four blocks, one <code>Mesh</code> each, in a <code>Group</code> whose origin is on the bottom edge the building will rotate around. The physics is a rigid body falling over: angular acceleration proportional to <code>g / h · sin θ</code>, with a small initial nudge so it starts moving. When the lean passes a threshold, the upper blocks detach and fly ballistically with the velocity they had at that instant:</p>
<pre><code class="language-javascript">function startTip(scene, box, h, onLand) {
  const n = Math.max(2, Math.min(4, Math.round(h / 9))), hs = h / n;
  const geo = new THREE.BoxGeometry(box.L1, box.L2, hs);
  const group = new THREE.Group();
  group.position.set(box.x + Math.cos(box.ang) * box.L1 / 2, box.y + Math.sin(box.ang) * box.L1 / 2, 0);
  group.rotation.z = box.ang;
  scene.add(group);

  const segs = [];
  for (let i = 0; i &lt; n; i++) {
    const m = new THREE.Mesh(geo, wallMaterial);
    m.frustumCulled = false;
    group.add(m);
    segs.push({ m, free: false, gone: false, x: -box.L1 / 2, z: hs * (i + 0.5), th: 0, px: 0, pz: 0 });
  }

  const tip = { th: 0.03, w: 0.22, broke: false, bend: 0.1 + Math.random() * 0.14, brk: 0.4 + Math.random() * 0.3 };

  function pose() {
    let bx = -box.L1 / 2, bz = 0;
    for (let j = 0; j &lt; n; j++) {
      const s = segs[j];
      if (s.free) continue;
      const th = tip.th * (1 + tip.bend * j);        // upper blocks bend a little further
      const sn = Math.sin(th), cs = Math.cos(th);
      if (j === 0) { bx = -box.L1 / 2 * cs; bz = box.L1 / 2 * sn; }
      s.px = s.x; s.pz = s.z;
      s.x = bx + sn * hs / 2; s.z = bz + cs * hs / 2; s.th = th;
      bx += sn * hs; bz += cs * hs;
      s.m.position.set(s.x, 0, s.z);
      s.m.rotation.y = th;
    }
  }

  return function tick(dt) {
    tip.w = Math.min(2.2, tip.w + 1.5 * 9.8 / h * Math.sin(tip.th) * dt);
    tip.th += Math.min(0.09, tip.w * dt);          // never more than ~5 degrees per frame
    pose();

    if (!tip.broke &amp;&amp; tip.th * (1 + tip.bend * (n - 1)) &gt; tip.brk &amp;&amp; n &gt; 1) {
      tip.broke = true;
      for (let j = 1; j &lt; n; j++) {
        const s = segs[j];
        s.free = true;
        s.vx = Math.max(-18, Math.min(18, (s.x - s.px) / Math.max(dt, 0.008)));
        s.vz = Math.max(-18, Math.min(18, (s.z - s.pz) / Math.max(dt, 0.008)));
        s.w = tip.w * (1 + tip.bend * j) * (1.05 + Math.random() * 0.25);
      }
    }

    let left = 0;
    for (const s of segs) {
      if (s.gone) continue;
      if (s.free) {
        s.vz -= 9.8 * dt; s.x += s.vx * dt; s.z += s.vz * dt; s.th += s.w * dt;
        s.m.position.set(s.x, 0, s.z);
        s.m.rotation.y = s.th;
      }
      const landed = s.free ? s.z &lt; Math.min(hs, box.L1) * 0.5 + 0.3 : tip.th &gt;= 1.25;
      if (landed) {
        s.gone = true;
        group.remove(s.m);
        onLand(group.position.x + Math.cos(box.ang) * s.x, group.position.y + Math.sin(box.ang) * s.x);
      } else left++;
    }
    if (!left) { scene.remove(group); geo.dispose(); return true; }   // finished
    return false;
  };
}
</code></pre>
<p>The clamps aren't decoration. Velocity at detachment is limited to 18 m/s and the frame step <code>dt</code> to a minimum of 8 ms, because the velocity is computed from a position difference, and a hiccup frame would otherwise launch a block into orbit. Angular velocity is limited to 2.5 rad/s and the lean to 0.09 rad per frame for the same reason. Every one of those numbers was added after watching a building do something a building can't do.</p>
<p><code>onLand</code> is where a collapse starts to look like one: each block that hits the ground spawns a burst of debris and dust at that point, and leaves a pile.</p>
<h2 id="heading-step-14-debris-and-dust">Step 14: Debris and Dust</h2>
<p>Debris is the instanced pools from Step 12 plus gravity. When a collapsing building passes a milestone (the first drop, each floor in floor-by-floor mode, a block landing), throw a handful of pieces out from the edges of the footprint, with velocity pointing away from the centre:</p>
<pre><code class="language-javascript">function throwDebris(pools, b, origin, kx, ky, top, n) {
  const ring = b.polys[0][0];
  const cx = (b.lng - origin.lng) * kx, cy = (b.lat - origin.lat) * ky;
  for (let i = 0; i &lt; n; i++) {
    // a random point on a random edge of the footprint
    const e = 1 + Math.floor(Math.random() * (ring.length - 1)), t = Math.random();
    const a = ring[e - 1], z = ring[e];
    const x = ((a[0] + (z[0] - a[0]) * t) - origin.lng) * kx;
    const y = ((a[1] + (z[1] - a[1]) * t) - origin.lat) * ky;
    // outward, with a little spread
    const ox = x - cx, oy = y - cy, ol = Math.hypot(ox, oy) || 1, v = 2 + Math.random() * 6;
    const size = 0.3 + Math.random() * 1.1;
    const idx = pools.rock.add({
      x, y, z: top * (0.35 + Math.random() * 0.65),
      yaw: Math.random() * 6, tx: Math.random() * 6, ty: Math.random() * 6,
      sx: size, sy: size * 0.8, sz: size * 0.6, rgb: concreteColour(),
    });
    if (idx &gt;= 0) flying.push({ idx, x, y, z: top, vx: ox / ol * v, vy: oy / ol * v, vz: Math.random() * 3, spin: (Math.random() - 0.5) * 8 });
  }
}

function tickDebris(pools, dt) {
  for (let i = flying.length - 1; i &gt;= 0; i--) {
    const p = flying[i];
    p.vz -= 9.8 * dt;
    p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
    p.tx += p.spin * dt;
    if (p.z &lt;= p.sz * 0.35) { p.z = p.sz * 0.35; flying.splice(i, 1); }   // landed: stays as rubble
    pools.rock.set(p.idx, p);
  }
}
</code></pre>
<p>Pieces that land simply stop being updated and stay in the pool. That's your rubble, for free.</p>
<p>HazardSim adds a few more things on top: stacked floor slabs on a 9-metre grid where a building stood, wall fragments with windows, bent rebar, a low fill-extrusion mound under the pile so the ground isn't flat, and for the closest buildings, a set of photogrammetry-scanned rubble models. Those are polish, and the pool plus gravity gets you most of the way.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/1544d432-3f78-480b-9412-fa4d0fa73e82.jpg" alt="Close-up at street level after the collapse. Dust hangs over a block of towers, the ground between them is strewn with grey rubble, and the sky is a haze of smoke lit from the side." style="display: block;" width="600" height="400" loading="lazy">

<p>Dust is the one place where I draw in screen space, and it's worth saying why that's fine here when it was fatal for water in Part 1. Dust particles are small, there are hundreds of them, and each lives three to eight seconds. They're also drawn as soft sprites with per-particle position, size, and rotation projected from a map point each frame. There are no edges to see and nothing to line up with. The screen-space rule is about large, persistent things with shape.</p>
<p>The important decision is <strong>where dust comes from</strong>. Early versions had a constant haze over the city and burning fires, and it looked like a screensaver. What reads as real is dust that comes only from events: a puff when a floor drops, a surge along the ground when a building lands, a jet from the ground floor of a soft-storey collapse. Each event emits a burst with a size proportional to the building's footprint and a vertical speed proportional to the drop, then nothing until the next event.</p>
<pre><code class="language-javascript">function emitDust(b, level, n, surge) {
  for (let i = 0; i &lt; n &amp;&amp; dust.length &lt; MAX_DUST; i++) {
    const a = Math.random() * Math.PI * 2;
    const speed = surge ? 5 + Math.random() * 8 : 1 + Math.random() * 3;
    dust.push({
      lng: b.lng, lat: b.lat,                                        // anchor on the map
      x: Math.cos(a) * b.rf * 0.6, y: Math.sin(a) * b.rf * 0.6,     // offset, metres
      z: surge ? Math.random() * 5 : level * (0.2 + Math.random() * 0.8),
      vx: Math.cos(a) * speed, vy: Math.sin(a) * speed,
      vz: surge ? 0.3 + Math.random() * 1.2 : 1.5 + Math.random() * 3,
      size: (surge ? b.rf * 0.5 + 6 : b.rf * 0.6 + 4) * (0.7 + Math.random() * 0.6),
      grow: 3 + Math.random() * 5,
      t: 0, life: (surge ? 5 : 3.5) + Math.random() * 2.5,
      sprite: sprites[Math.floor(Math.random() * sprites.length)],
      alpha: 0.5 + Math.random() * 0.3,
    });
  }
}
</code></pre>
<p>Per frame, each particle decelerates, drifts with the wind, grows, and is projected: <code>map.project([lng + x / kx, lat + y / ky])</code> gives the screen position of its ground point, and the height is applied as <code>y -= z * pixelsPerMetre * sin(pitch)</code>. Sort by screen depth, draw back to front with <code>drawImage</code>, and fade with age. It's a few hundred <code>drawImage</code> calls per frame on a 2D canvas over the map, and it costs almost nothing.</p>
<h2 id="heading-step-15-the-camera-has-to-shake">Step 15: The Camera Has to Shake</h2>
<p>Everything so far could be running silently in a corner of the screen. What makes a viewer feel the earthquake is the camera.</p>
<p>Two things. First, the dive: from the overview, ease to zoom 14.6 at 45 degrees, hold until tiles are loaded, then ease to zoom 16.2 at 62 degrees over the epicentre. The hold isn't optional. If you dive straight to zoom 16, the vector tiles for that zoom haven't arrived, <code>querySourceFeatures</code> returns almost nothing, and the earthquake hits an empty city.</p>
<pre><code class="language-javascript">function diveToCity(map, center, then) {
  const bearing = map.getBearing();
  map.easeTo({ center, zoom: 14.6, pitch: 45, bearing: bearing - 10, duration: 1100, essential: true });
  const t1 = Date.now();
  (function hold() {
    const loaded = !map.isMoving() &amp;&amp; map.areTilesLoaded();
    if (Date.now() - t1 &gt; 1000 &amp;&amp; (loaded || Date.now() - t1 &gt; 2000)) {
      map.easeTo({ center, zoom: 16.2, pitch: 62, bearing: bearing - 25, duration: 1500, essential: true });
      map.once("moveend", then);
    } else setTimeout(hold, 150);
  })();
}
</code></pre>
<p>Second, the shake. Don't move the map camera for this. A camera that jitters through <code>easeTo</code> fights the animation system and loads tiles for every jitter. Shake the <em>container</em> with a CSS transform. The map doesn't know it moved:</p>
<pre><code class="language-javascript">function shake(durationMs, magnitude) {
  const el = map.getContainer();
  const w = window.innerWidth, h = window.innerHeight;
  const amp = Math.min(28, w * 0.02, Math.max(6, (magnitude - 4) * 4.2));
  const dir = Math.random() * Math.PI;
  const t0 = performance.now();
  let last = t0, phase = 0;

  function tick(now) {
    const k = (now - t0) / durationMs;
    if (k &gt;= 1) { el.style.transform = ""; return; }
    const dt = Math.min(0.05, (now - last) / 1000); last = now;

    // builds up over the first third, then decays; frequency rises with it
    let env, omega;
    if (k &lt; 0.35) { const u = k / 0.35; env = 0.15 + 0.85 * u * u * (3 - 2 * u); omega = 7 + 39 * u * u; }
    else          { const v = (k - 0.35) / 0.65; env = Math.pow(1 - v, 1.4); omega = 46 - 18 * v; }
    phase += omega * dt;

    const a = amp * env;
    const osc = Math.sin(phase) * 0.78 + (Math.random() - 0.5) * 0.44 * Math.min(1, omega / 30);
    const x = Math.cos(dir) * osc * a, y = Math.sin(dir) * osc * a * 0.7;
    el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${(osc * a * 0.012).toFixed(3)}deg) scale(${(1 + 2.4 * amp / Math.min(w, h) * env).toFixed(4)})`;
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
}
</code></pre>
<p>The envelope matters more than the amplitude. Real shaking builds for a second or two, peaks, and dies off over several more, with the frequency rising as it builds. A constant jitter reads as a broken video. An aftershock at about half the amplitude, three or four seconds after the main shock, finishes the effect.</p>
<p>When the shaking stops, ease the camera down to the centre of the nearest collapses and hold for a few seconds so the rubble gets its moment, then pull back up to the intensity rings and the casualty figures.</p>
<h2 id="heading-keeping-it-fast">Keeping It Fast</h2>
<p>A few decisions kept this at 60 fps on a laptop and usable on phones.</p>
<h3 id="heading-1-pools-with-fixed-capacity">1. Pools with Fixed Capacity</h3>
<p>Every instanced pool has a cap, and <code>add</code> returns <code>-1</code> when it's full. The caps scale down for low-power devices: 9,000 slabs instead of 30,000, 3,000 rocks instead of 9,000. The scene degrades to fewer pieces rather than to a slideshow.</p>
<h3 id="heading-2-a-device-tier-decided-once">2. A Device Tier, Decided Once</h3>
<p>Mobile user agent, <code>navigator.deviceMemory &lt; 6</code>, <code>hardwareConcurrency &lt;= 4</code>, or a short screen side under 700 px puts the device in the low tier. The low tier skips the <code>three.js</code> tipping entirely and uses only shrinking extrusions, caps dust at a third, and drops the hero models.</p>
<h3 id="heading-3-rebuild-the-rubble-mound-at-most-every-12-seconds">3. Rebuild the Rubble Mound at Most Every 1.2 Seconds</h3>
<p>The low <code>fill-extrusion</code> mound under each pile is a GeoJSON source, and <code>setData</code> on a GeoJSON source re-parses it. Doing that once per collapsed building was the main cause of stutter in early versions. Now landed buildings go into a pending list that's flushed on a timer.</p>
<h3 id="heading-4-never-remove-a-collapsed-building-from-the-source">4. Never Remove a Collapsed Building from the Source</h3>
<p>It stays in <code>bld</code> with <code>k</code> set to 0.001. Removing features means <code>setData</code>, and <code>setData</code> means a stall.</p>
<h3 id="heading-5-load-assets-on-intent">5. Load Assets on Intent</h3>
<p>The textures and models for the full-detail mode add up to 24 MB. They start downloading when the visitor clicks the earthquake tab, not on page load, and only in the full tier.</p>
<h2 id="heading-what-went-wrong-and-why">What Went Wrong, and Why</h2>
<p>Each of these cost me real time, and the first two are why Part 1 exists at all.</p>
<h3 id="heading-the-sticker-problem">The Sticker Problem</h3>
<p>My first version of a "wall of water" was a video clip drawn on a screen-space canvas, positioned by projecting a map point each frame. On a 13-inch laptop it almost worked. On a 4K monitor it was a rectangle with soft edges hovering over the city, obviously not part of the scene.</p>
<p>No amount of tuning opacity, feathering, or size fixes this, because the problem isn't the sprite. A sprite has no place in a 3D world. Everything in Steps 3 to 6 is the alternative.</p>
<p>If you do need screen-space elements, say a splash sprite anchored to a point, keep them small, short-lived, and numerous, and accept that they'll never carry the shot.</p>
<h3 id="heading-i-couldnt-find-my-layer-twice">I Couldn't Find My Layer, Twice</h3>
<p>The 3D water wall from the end of Part 1 rendered without an error, the mesh moved exactly where my debug probe said it should, and nothing appeared on screen. I'd added it with <code>map.addLayer(layer, "flood")</code>, which inserts it <em>below</em> the flood raster, and the opaque water was hiding my wall. Check <code>map.getStyle().layers</code> before you check anything else.</p>
<p>The second time I lost a layer, it was the opposite problem: it wasn't there at all. <code>map.getStyle().layers</code> doesn't list custom layers. Check with <code>map.getLayer("debris3d")</code> instead. I spent a while convinced the layer wasn't being added.</p>
<h3 id="heading-24-levels-of-transparency">24 Levels of Transparency</h3>
<p>I built sprite sheets with soft alpha edges and ran them through <code>PIL.Image.quantize()</code> to shrink the PNG. The palette compression quantized the alpha channel along with the colours. 256 levels became 24, the smooth falloff became a staircase, and every step was a hard straight edge that looked exactly like a tile boundary. I spent two rounds hunting for a tiling bug that didn't exist.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/bba497b2-fe0e-4398-a7af-40a4245674f1.png" alt="Two versions of the same smoke sprite on a dark background. Left, after palette quantization: the zoomed edge is made of blocky pixels in only a few shades, and the alpha histogram shows eight isolated spikes. Right, full 8-bit alpha: the zoomed edge fades smoothly, and the histogram is a continuous spread of 253 levels." style="display: block;" width="600" height="400" loading="lazy">

<p>The fix was to stop quantizing and make the RGB channels constant (the sprites were white anyway), so PNG compression had nothing left to do. The file got three times smaller. I now check alpha with a histogram instead of trusting my eyes.</p>
<h3 id="heading-same-filename-different-file">Same Filename, Different File</h3>
<p>I re-encoded <code>flood.webm</code> with a different grade and redeployed. Visitors kept getting the old one for hours. The CDN cached by URL, and a <code>?v=2</code> query string didn't bust it for a file that already existed.</p>
<p>Renaming the file to <code>flood2.webm</code> fixed it in one deploy. Since then I rename every binary asset I update instead of versioning the URL.</p>
<h3 id="heading-headless-screenshots-lied-twice">Headless Screenshots Lied, Twice</h3>
<p>I tested under headless Chrome with a software GL renderer. Each screenshot took a second or two to capture, so a "sequence" of frames was nothing of the sort, and I spent an afternoon tuning timings against noise.</p>
<p>Two things fixed it: record the browser session as video and cut frames with ffmpeg, and, for colour, read the simulation canvas directly with <code>getImageData</code> instead of measuring the composited screen. The second one sounds obvious written down, but it took me an afternoon to arrive at it.</p>
<p>The same headless setup fooled me again on the earthquake. Satellite tiles load slowly there, and for a long time I thought my building layer was broken because screenshots showed a black city. It was the base map that hadn't arrived. If a layer looks wrong in headless tests, wait for <code>map.areTilesLoaded()</code> before trusting the screenshot.</p>
<h3 id="heading-nothing-happened-when-i-set-feature-state">Nothing Happened When I Set Feature State</h3>
<p><code>setFeatureState</code> against the tile source did nothing and reported nothing, because the features have no IDs. The fix is the GeoJSON copy with <code>promoteId</code> from Step 11. It took me an embarrassingly long time to find, because the API doesn't fail. It just doesn't do anything.</p>
<h3 id="heading-every-instance-was-black">Every Instance Was Black</h3>
<p><code>InstancedMesh.setColorAt</code> in three r128 lazily creates the colour buffer the first time you call it, sized to the mesh's <strong>current</strong> <code>count</code>. My pools started with <code>count = 0</code>, so the buffer was created with zero entries, and every piece rendered black. Allocate <code>instanceColor</code> yourself, for the full capacity, before the first <code>setColorAt</code>. That's the line in <code>makePool</code>.</p>
<h3 id="heading-half-the-debris-vanished-when-i-panned">Half the Debris Vanished When I Panned</h3>
<p><code>three.js</code> frustum-culls meshes against its own camera. Our camera is a bare <code>THREE.Camera</code> with a projection matrix stuffed into it each frame and no idea where the map is looking, so its idea of the frustum is nonsense. Set <code>frustumCulled = false</code> on every mesh in the custom layer.</p>
<h3 id="heading-the-scene-updated-but-the-screen-didnt">The Scene Updated But the Screen Didn't</h3>
<p>The map only repaints when it thinks something changed. Call <code>map.triggerRepaint()</code> after you move your objects. Without it the animation runs at whatever rate the map happens to repaint for other reasons, which in a still scene is zero.</p>
<h3 id="heading-a-sky-made-everything-disappear">A Sky Made Everything Disappear</h3>
<p>On a different project with the same setup, <code>map.setSky(...)</code> hid the whole <code>three.js</code> scene. The sky writes to the depth buffer, and everything the custom layer draws afterwards fails the depth test. If you render 3D into the map, don't enable the sky layer.</p>
<h3 id="heading-the-idle-event-never-fired-in-a-background-tab">The <code>idle</code> Event Never Fired in a Background Tab</h3>
<p>I preloaded models on <code>map.on("idle")</code>. In a background tab the map can go a very long time without becoming idle, and visitors who opened the site in a tab and came back later got an earthquake with no models. The preload moved to the moment the hazard type is selected.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The whole guide reduces to two structures.</p>
<p>On the ground:</p>
<ol>
<li><p>Align a grid to the map's tile pyramid, so every tiled dataset drops in without resampling.</p>
</li>
<li><p>Decode free elevation tiles into that grid.</p>
</li>
<li><p>Register a canvas as a map source over the grid's rectangle, with <code>animate: true</code>.</p>
</li>
<li><p>Run the simulation on the grid once. Animate by writing pixels every frame.</p>
</li>
<li><p>Use <code>source-atop</code> and <code>destination-in</code> to drape textures exactly inside the simulated shape.</p>
</li>
<li><p>Move the camera. The drawing comes along.</p>
</li>
</ol>
<p>With height:</p>
<ol>
<li><p>Pull buildings from the vector tiles the map already loaded. Deduplicate, as they have no IDs.</p>
</li>
<li><p>Draw them all as <code>fill-extrusion</code> with a per-feature height multiplier, through a GeoJSON copy with <code>promoteId</code> so feature state works.</p>
</li>
<li><p>Decide damage from an attenuation relation and a fragility curve, with randomness per building.</p>
</li>
<li><p>Animate collapse by writing the multiplier each frame.</p>
</li>
<li><p>For the buildings near the camera, render <code>three.js</code> into the map's own GL context with <code>renderingMode: "3d"</code>, so the depth buffer is shared.</p>
</li>
<li><p>Turn footprints into oriented boxes, tip them over, snap them into blocks, and throw instanced debris. Emit dust only from events. Shake the container, not the camera.</p>
</li>
</ol>
<p>The idea under all of it is the one I kept relearning: anything that belongs to the world has to be drawn in the world's coordinates. A canvas on top of the map is a sheet of glass. A canvas inside the map is the ground, and a <code>three.js</code> scene inside the map's GL context is the air above it.</p>
<p>Every improvement that made either simulation look real came from moving something out of the first category and into the second.</p>
<p>The other thing I'd tell myself at the start: the map engine and <code>three.js</code> are not competitors. The map is better at ten thousand static buildings than anything you'll write, and <code>three.js</code> is better at ten moving ones than any expression language.</p>
<p>Give each the job it's good at, and spend your effort on the seam between them, which is almost entirely the coordinate transform in Step 12 and the feature-state trick in Step 11.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/3ed70355-abc0-4a98-8285-8012ad46aff9.jpg" alt="The finished scenario: a 30-metre tsunami in Odesa seen from above, with brown floodwater covering the coastal districts and a results panel on the left listing people killed, area flooded, inland reach and maximum depth." style="display: block;" width="600" height="400" loading="lazy">

<p>You can see both scenarios at <a href="https://hazardsim.com/">hazardsim.com</a>. Pick a coastal city and launch a tsunami, or pick any city, set a magnitude, and press the button. The code in this guide is a cleaned-up version of what runs there. Thanks to Tim for breaking it in ways I never would have.</p>
 ]]>
                </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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" 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="600" height="400" loading="lazy"></a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Real-Time Word Counter Tool with HTML, CSS, and JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ Whether you're writing an essay, a tweet, or a blog post, keeping track of your word and character count is very important. In this tutorial, you'll build a fully functional, real-time Word Counter to ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-real-time-word-counter-tool-with-html-css-and-javascript/</link>
                <guid isPermaLink="false">6abca43ca3fbe2215db75ecf</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ HTML5 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CSS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Beginner Developers ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bansidhar Kadiya ]]>
                </dc:creator>
                <pubDate>Wed, 30 Sep 2026 05:55:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d32feb51-3930-4bd7-be76-9a9150d069d1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Whether you're writing an essay, a tweet, or a blog post, keeping track of your word and character count is very important.</p>
<p>In this tutorial, you'll build a fully functional, real-time Word Counter tool from scratch. You'll use HTML for the structure, CSS for a clean design, and vanilla JavaScript to handle the counting logic.</p>
<p>Because this tool runs completely in the browser, it works instantly as you type and keeps your text data completely private.</p>
<p>Let's get started.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow this guide easily, you should have:</p>
<ul>
<li><p>A basic understanding of HTML tags and CSS styling.</p>
</li>
<li><p>Familiarity with JavaScript concepts like functions, event listeners, and basic Regular Expressions (Regex).</p>
</li>
<li><p>A code editor (like VS Code) and a web browser.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-step-1-set-up-your-project">Step 1: Set Up Your Project</a></p>
</li>
<li><p><a href="#heading-step-2-build-the-html-structure">Step 2: Build the HTML Structure</a></p>
<ul>
<li><a href="#heading-understanding-the-html">Understanding the HTML:</a></li>
</ul>
</li>
<li><p><a href="#heading-step-3-style-the-tool-with-css">Step 3: Style the Tool with CSS</a></p>
<ul>
<li><a href="#heading-understanding-the-css">Understanding the CSS:</a></li>
</ul>
</li>
<li><p><a href="#heading-step-4-add-the-javascript-logic">Step 4: Add the JavaScript Logic</a></p>
<ul>
<li><a href="#heading-understanding-the-javascript">Understanding the JavaScript:</a></li>
</ul>
</li>
<li><p><a href="#heading-step-5-test-your-application">Step 5: Test Your Application</a></p>
</li>
</ul>
<h2 id="heading-step-1-set-up-your-project">Step 1: Set Up Your Project</h2>
<p>First, you need to set up your workspace. Create a new folder on your computer and name it <code>word-counter-app</code>.</p>
<p>Inside this folder, create three empty files:</p>
<ul>
<li><p><code>index.html</code></p>
</li>
<li><p><code>style.css</code></p>
</li>
<li><p><code>script.js</code></p>
</li>
</ul>
<h2 id="heading-step-2-build-the-html-structure">Step 2: Build the HTML Structure</h2>
<p>Open your <code>index.html</code> file. You need to create a layout that includes a header, a grid to display the real-time statistics, a text area for the user to type in, and buttons to copy or clear the text.</p>
<p>Add the following code into your HTML file:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
    &lt;meta charset="UTF-8"&gt;
    &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
    &lt;title&gt;Word Counter Tool&lt;/title&gt;
    &lt;link rel="stylesheet" href="style.css"&gt;
&lt;/head&gt;
&lt;body&gt;

    &lt;header class="header-section"&gt;
        &lt;h1&gt;Word Counter&lt;/h1&gt;
        &lt;p class="description"&gt;Paste or type your text below to get a real-time count of words, characters, sentences, and paragraphs.&lt;/p&gt;
    &lt;/header&gt;

    &lt;main class="tool-container"&gt;
        
        &lt;!-- Statistics Panel --&gt;
        &lt;div class="stats-grid"&gt;
            &lt;div class="stat-box"&gt;
                &lt;div class="stat-value" id="wordCount"&gt;0&lt;/div&gt;
                &lt;div class="stat-label"&gt;Words&lt;/div&gt;
            &lt;/div&gt;
            &lt;div class="stat-box"&gt;
                &lt;div class="stat-value" id="charCount"&gt;0&lt;/div&gt;
                &lt;div class="stat-label"&gt;Characters&lt;/div&gt;
            &lt;/div&gt;
            &lt;div class="stat-box"&gt;
                &lt;div class="stat-value" id="sentenceCount"&gt;0&lt;/div&gt;
                &lt;div class="stat-label"&gt;Sentences&lt;/div&gt;
            &lt;/div&gt;
            &lt;div class="stat-box"&gt;
                &lt;div class="stat-value" id="paragraphCount"&gt;0&lt;/div&gt;
                &lt;div class="stat-label"&gt;Paragraphs&lt;/div&gt;
            &lt;/div&gt;
        &lt;/div&gt;

        &lt;!-- User Input Area --&gt;
        &lt;textarea id="textInput" placeholder="Start typing or paste your text here..."&gt;&lt;/textarea&gt;

        &lt;!-- Action Buttons --&gt;
        &lt;div class="controls"&gt;
            &lt;button class="btn-primary" onclick="copyText()" id="copyBtn"&gt;Copy Text&lt;/button&gt;
            &lt;button class="btn-secondary" onclick="clearText()"&gt;Clear&lt;/button&gt;
        &lt;/div&gt;

    &lt;/main&gt;

    &lt;script src="script.js"&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Understanding the HTML:</p>
<ul>
<li><p><strong>The</strong> <code>.stats-grid</code><strong>:</strong> This holds four separate boxes to show the counts. Each number has a unique <code>id</code> (like <code>wordCount</code>) so JavaScript can find and update it easily.</p>
</li>
<li><p><strong>The</strong> <code>&lt;textarea&gt;</code><strong>:</strong> This is the main input box where users will type or paste their content.</p>
</li>
<li><p><strong>The</strong> <code>&lt;script&gt;</code> <strong>tag:</strong> This connects your HTML to the logic you will write in the next steps.</p>
</li>
</ul>
<h2 id="heading-step-3-style-the-tool-with-css">Step 3: Style the Tool with CSS</h2>
<p>Next, you'll give your tool a modern, professional look. You'll use CSS Grid to align the statistics boxes perfectly.</p>
<p>Open your <code>style.css</code> file and add this code:</p>
<pre><code class="language-css">:root {
    --primary-color: #007bff;
    --bg-color: #f8f9fa;
    --text-dark: #202124;
    --text-muted: #5f6368;
    --border-color: #dadce0;
    --panel-bg: #ffffff;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    background-color: var(--bg-color);
    color: var(--text-dark);
    margin: 0;
    padding: 40px 20px;
    display: flex;
    flex-direction: column;
    align-items: center;
}

.header-section {
    text-align: center;
    margin-bottom: 30px;
}

h1 {
    font-size: 2.5rem;
    margin: 0 0 10px 0;
    font-weight: 800;
}

p.description {
    color: var(--text-muted);
    max-width: 600px;
    margin: 0 auto;
    line-height: 1.6;
    font-size: 1.1rem;
}

.tool-container {
    background-color: var(--panel-bg);
    border: 1px solid var(--border-color);
    border-radius: 12px;
    padding: 24px;
    width: 100%;
    max-width: 900px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.05);
}

/* Stats Grid */
.stats-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: 16px;
    margin-bottom: 24px;
}

.stat-box {
    background-color: var(--bg-color);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    padding: 16px;
    text-align: center;
}

.stat-value {
    font-size: 2rem;
    font-weight: 700;
    color: var(--primary-color);
    margin-bottom: 4px;
}

.stat-label {
    font-size: 0.9rem;
    color: var(--text-muted);
    text-transform: uppercase;
    letter-spacing: 0.5px;
    font-weight: 600;
}

/* Text Area */
textarea {
    width: 100%;
    height: 250px;
    padding: 16px;
    border: 1px solid var(--border-color);
    border-radius: 8px;
    font-size: 1rem;
    line-height: 1.6;
    resize: vertical;
    box-sizing: border-box;
    font-family: inherit;
    margin-bottom: 20px;
    transition: border-color 0.2s ease;
}

textarea:focus {
    outline: none;
    border-color: var(--primary-color);
}

/* Buttons */
.controls {
    display: flex;
    gap: 12px;
}

button {
    padding: 10px 20px;
    font-size: 1rem;
    font-weight: 600;
    border-radius: 6px;
    cursor: pointer;
    border: none;
    transition: background-color 0.2s ease;
}

.btn-primary {
    background-color: var(--primary-color);
    color: #ffffff;
}

.btn-primary:hover {
    background-color: #0056b3;
}

.btn-secondary {
    background-color: transparent;
    color: var(--text-dark);
    border: 1px solid var(--border-color);
}

.btn-secondary:hover {
    background-color: var(--bg-color);
}
</code></pre>
<p>Understanding the CSS:</p>
<ul>
<li><p><strong>CSS Grid:</strong> The <code>.stats-grid</code> class uses <code>grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));</code>. This makes the four stat boxes automatically stack neatly on top of each other if a user views the tool on a small mobile screen.</p>
</li>
<li><p><strong>CSS Variables:</strong> Using <code>:root</code> at the top allows you to quickly change the brand color later if you want to use something other than the default blue (<code>#007bff</code>).</p>
</li>
</ul>
<p>At this point, the visual design of your tool is complete. Here's what the final Word Counter will look like in your browser:</p>
<img src="https://cdn.hashnode.com/uploads/covers/699c7b22cf5def0f6aaf982b/dc292767-ac0f-4e87-b88c-d64fab38f370.png" alt="Word Counter Tool" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-4-add-the-javascript-logic">Step 4: Add the JavaScript Logic</h2>
<p>Now you need to make the tool count the text. You'll use an event listener that watches every keystroke. Every time the user types, it recalculates the words, characters, sentences, and paragraphs.</p>
<p>Open your <code>script.js</code> file and paste this code:</p>
<pre><code class="language-javascript">const textInput = document.getElementById('textInput');
const wordCountDisplay = document.getElementById('wordCount');
const charCountDisplay = document.getElementById('charCount');
const sentenceCountDisplay = document.getElementById('sentenceCount');
const paragraphCountDisplay = document.getElementById('paragraphCount');
const copyBtn = document.getElementById('copyBtn');

// 1. Listen for user input in real-time
textInput.addEventListener('input', updateStatistics);

// 2. The Core Counting Logic
function updateStatistics() {
    const text = textInput.value;

    // Character Count (includes spaces)
    charCountDisplay.textContent = text.length;

    // Word Count
    const words = text.match(/\S+/g) || [];
    wordCountDisplay.textContent = words.length;

    // Sentence Count
    const sentences = text.split(/[.!?]+(?=\s|$)/).filter(sentence =&gt; sentence.trim().length &gt; 0);
    sentenceCountDisplay.textContent = sentences.length;

    // Paragraph Count
    const paragraphs = text.split(/\n+/).filter(paragraph =&gt; paragraph.trim().length &gt; 0);
    paragraphCountDisplay.textContent = paragraphs.length;
}

// 3. Copy functionality
function copyText() {
    if (!textInput.value) return;
    
    textInput.select();
    document.execCommand('copy');
    
    // Provide visual feedback
    copyBtn.textContent = 'Copied!';
    setTimeout(() =&gt; {
        copyBtn.textContent = 'Copy Text';
    }, 1500);
}

// 4. Clear functionality
function clearText() {
    textInput.value = '';
    updateStatistics(); // Reset counts to zero
}
</code></pre>
<p>Understanding the JavaScript:</p>
<ul>
<li><p><strong>The</strong> <code>input</code> <strong>Event:</strong> <code>textInput.addEventListener('input', ...)</code> is the secret to real-time updates. It triggers the counting function the exact moment a key is pressed or text is pasted.</p>
</li>
<li><p><strong>Counting Words:</strong> <code>text.match(/\S+/g)</code> is a Regular Expression that looks for unbroken strings of non-whitespace characters. This is much more accurate than just splitting the text by spaces, because it ignores extra empty spaces left by mistake.</p>
</li>
<li><p><strong>Counting Sentences:</strong> The <code>.split(/[.!?]+(?=\s|$)/)</code> logic cuts the text into pieces every time it sees a period, exclamation mark, or question mark followed by a space.</p>
</li>
</ul>
<h2 id="heading-step-5-test-your-application">Step 5: Test Your Application</h2>
<p>You're completely done coding! Now it's time to verify that your logic works correctly.</p>
<ol>
<li><p>Open your <code>word-counter-app</code> folder.</p>
</li>
<li><p>Double-click the <code>index.html</code> file to open it in your web browser.</p>
</li>
<li><p>Type a few sentences into the text area. Watch the numbers at the top update instantly.</p>
</li>
<li><p>Try adding double spaces or hitting the "Enter" key to make new paragraphs, and ensure the logic counts them accurately.</p>
</li>
<li><p>Click <strong>Copy Text</strong> to test the clipboard feature, and <strong>Clear</strong> to reset the board.</p>
</li>
</ol>
<p>By completing this project, you've built a fast, client-side utility tool using pure vanilla JavaScript. You learned how to manipulate strings, use Regular Expressions for text analysis, and create responsive UI grids.</p>
<p>If you want to see this exact codebase running in a live production environment, you can try out this <a href="https://99tools.net/word-counter/">Online Word Counter</a>. Keep building, and happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use the Fullscreen API in JavaScript (and Keep the Screen Awake with the Wake Lock API) ]]>
                </title>
                <description>
                    <![CDATA[ Sooner or later, most front-end developers hit the same request: "Can this take up the whole screen?" A slide deck, a video player, kiosk dashboard, game, drawing canvas, or timer on a classroom proje ]]>
                </description>
                <link>https://www.freecodecamp.org/news/fullscreen-api-javascript-wake-lock/</link>
                <guid isPermaLink="false">6abbbdbe89f93b150a3b95f4</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ browser-apis ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Oliinyk ]]>
                </dc:creator>
                <pubDate>Tue, 29 Sep 2026 13:31:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/32fd0d68-ea3d-40c2-8653-449de3d514f1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Sooner or later, most front-end developers hit the same request: <em>"Can this take up the whole screen?"</em></p>
<p>A slide deck, a video player, kiosk dashboard, game, drawing canvas, or timer on a classroom projector all feel half-finished while the browser's tabs and address bar are still hanging around the edges.</p>
<p>The good news is that the browser has a built-in answer: the <strong>Fullscreen API</strong>. The less good news is that it comes with a handful of quirks that will bite you the first time you ship it: user gestures, Safari prefixes, iPhones that simply refuse, and a screen that goes to sleep two minutes into your beautiful fullscreen experience.</p>
<p>In this tutorial, you'll learn how to:</p>
<ul>
<li><p>Put any element (or the whole page) into fullscreen and back out again</p>
</li>
<li><p>Keep your UI in sync when the user presses <code>Esc</code></p>
</li>
<li><p>Style fullscreen content with the <code>:fullscreen</code> pseudo-class</p>
</li>
<li><p>Handle the cross-browser gotchas, including the one platform that still doesn't support it</p>
</li>
<li><p>Stop the screen from dimming with the <strong>Screen Wake Lock API</strong></p>
</li>
<li><p>Combine all of it into one small, reusable pattern</p>
</li>
</ul>
<p>Everything here is vanilla JavaScript with no libraries or build step. You should be comfortable with DOM events and <code>async/await</code>. If you need a refresher on the latter, freeCodeCamp has a solid <a href="https://www.freecodecamp.org/news/javascript-async-await/">async/await tutorial</a> that covers everything we'll rely on.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-the-fullscreen-api-works">How the Fullscreen API Works</a></p>
</li>
<li><p><a href="#heading-entering-and-exiting-fullscreen">Entering and Exiting Fullscreen</a></p>
</li>
<li><p><a href="#heading-the-user-gesture-rule">The User Gesture Rule</a></p>
</li>
<li><p><a href="#heading-keeping-your-ui-in-sync-with-fullscreenchange">Keeping Your UI in Sync with fullscreenchange</a></p>
</li>
<li><p><a href="#heading-styling-fullscreen-content">Styling Fullscreen Content</a></p>
</li>
<li><p><a href="#heading-cross-browser-gotchas">Cross-Browser Gotchas</a></p>
</li>
<li><p><a href="#heading-keeping-the-screen-awake-with-the-wake-lock-api">Keeping the Screen Awake with the Wake Lock API</a></p>
</li>
<li><p><a href="#heading-putting-it-all-together">Putting It All Together</a></p>
</li>
<li><p><a href="#heading-a-quick-gotcha-checklist">A Quick Gotcha Checklist</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-how-the-fullscreen-api-works">How the Fullscreen API Works</h2>
<p>The Fullscreen API is small. It gives you three things you'll use every day:</p>
<ul>
<li><p><code>element.requestFullscreen()</code>: asks the browser to display that element (and its descendants) using the entire screen. It returns a Promise.</p>
</li>
<li><p><code>document.exitFullscreen()</code>: leaves fullscreen. Also returns a Promise.</p>
</li>
<li><p><code>document.fullscreenElement</code>: the element currently in fullscreen, or <code>null</code> if there isn't one. This is your single source of truth for "are we fullscreen right now?"</p>
</li>
</ul>
<p>Plus two events on <code>document</code>: <code>fullscreenchange</code> (fires when fullscreen is entered <em>or</em> exited) and <code>fullscreenerror</code> (fires if a request fails).</p>
<p>One thing that surprises people: you can make <em>any</em> element fullscreen, not just the page. If you fullscreen a <code>&lt;div&gt;</code>, only that <code>&lt;div&gt;</code> fills the screen. Everything else in the document is hidden behind it.</p>
<p>To fullscreen the whole page, you call the method on <code>document.documentElement</code> (the <code>&lt;html&gt;</code> element).</p>
<p>The full reference lives on <a href="https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API">MDN's Fullscreen API page</a>, but you won't need much beyond what's above.</p>
<h2 id="heading-entering-and-exiting-fullscreen">Entering and Exiting Fullscreen</h2>
<p>Let's start with the simplest useful thing: a button that toggles the page in and out of fullscreen.</p>
<pre><code class="language-html">&lt;button id="fs-toggle"&gt;Fullscreen&lt;/button&gt;
</code></pre>
<pre><code class="language-javascript">const toggleBtn = document.getElementById('fs-toggle');

async function toggleFullscreen() {
  if (!document.fullscreenElement) {
    // Nothing is fullscreen yet – enter it.
    await document.documentElement.requestFullscreen();
  } else {
    // Something is fullscreen – leave it.
    await document.exitFullscreen();
  }
}

toggleBtn.addEventListener('click', toggleFullscreen);
</code></pre>
<p>That's genuinely it for the happy path. Because both methods return Promises, you can <code>await</code> them and know that the transition is finished before running any follow-up code.</p>
<p>Both calls can reject, though. For example, if the document isn't allowed to go fullscreen (we'll get to why below), or if you call <code>exitFullscreen()</code> when nothing is fullscreen. So in real code, wrap them in <code>try/catch</code> rather than letting an unhandled rejection land in the console:</p>
<pre><code class="language-javascript">async function toggleFullscreen() {
  try {
    if (!document.fullscreenElement) {
      await document.documentElement.requestFullscreen();
    } else {
      await document.exitFullscreen();
    }
  } catch (err) {
    console.warn(`Fullscreen failed: ${err.name} – ${err.message}`);
  }
}
</code></pre>
<h2 id="heading-the-user-gesture-rule">The User Gesture Rule</h2>
<p>Here's the first gotcha, and it's the one that generates the most confused questions on Stack Overflow: <strong>you can't enter fullscreen automatically</strong>. Not on page load, after a timer, or in response to a network event.</p>
<p><code>requestFullscreen()</code> only works while the page has what the spec calls <em>transient activation</em>: a short window right after the user genuinely interacts with the page (like with a click, tap, or key press). Outside that window the Promise rejects, and in most browsers you'll see a message like <em>"API can only be initiated by a user gesture."</em></p>
<p>This is deliberate. Without it, any page could hijack your whole screen the moment it loaded, which is exactly the kind of thing phishing pages would love to do.</p>
<p>In practice, this means two things for your code:</p>
<ol>
<li><p>Always trigger fullscreen from an event handler tied to user input: <code>click</code>, <code>keydown</code>, <code>pointerup</code>, and so on.</p>
</li>
<li><p>Don't put an <code>await</code> for something slow <em>before</em> the <code>requestFullscreen()</code> call. If you <code>await fetch(...)</code> first, the activation window may have expired by the time you ask for fullscreen.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/f635bd25-b6ac-44f4-9de2-e06a8fe5fe19.png" alt="Diagram of the user gesture rule: calling requestFullscreen() directly inside a click handler succeeds, while awaiting a slow fetch first lets the activation window expire and the call is rejected." style="display: block;" width="600" height="400" loading="lazy">

<p>Call <code>requestFullscreen()</code> first and do the slow work after, or the activation window closes before you ask.</p>
<p>A common and very natural pattern is to let a keyboard shortcut do the same job as the button:</p>
<pre><code class="language-javascript">document.addEventListener('keydown', (e) =&gt; {
  // Ignore shortcuts while the user is typing in a field.
  if (e.target.matches('input, textarea, [contenteditable]')) return;

  if (e.key === 'f' || e.key === 'F') {
    e.preventDefault();
    toggleFullscreen();
  }
});
</code></pre>
<p>Two small things I got wrong the first time around are worth passing on.</p>
<p>First, make the fullscreen surface itself keyboard-reachable: give it <code>tabindex="0"</code> and treat <code>Enter</code> and <code>Space</code> on it exactly like a click, otherwise keyboard users have a button nobody told them about.</p>
<p>Second, think about whether your shortcut key collides with what the page actually does. I have a "hacker typer" page where any key spits out fake terminal output, including <code>F</code>. For the first few days, users typing furiously would drop out of fullscreen mid-"hack" every time they hit that letter. The fix was a one-liner: on that page <code>F</code> only ever <em>enters</em> fullscreen, and leaving is <code>Esc</code>'s job alone.</p>
<p>Which brings up <code>Esc</code>: you don't handle it yourself. Every browser exits fullscreen on <code>Esc</code>, and you can't prevent that. It's a safety escape hatch. What you <em>can</em> do is react to it, which is the next section.</p>
<h2 id="heading-keeping-your-ui-in-sync-with-fullscreenchange">Keeping Your UI in Sync with <code>fullscreenchange</code></h2>
<p>Because the user can leave fullscreen in ways your code doesn't control (like by pressing <code>Esc</code>, using the browser's own exit button, or switching apps on mobile), you should never track fullscreen state in your own variable. It will drift out of sync.</p>
<p>Instead, treat <code>document.fullscreenElement</code> as the truth and update your UI whenever <code>fullscreenchange</code> fires:</p>
<pre><code class="language-javascript">function syncFullscreenUI() {
  const isFullscreen = Boolean(document.fullscreenElement);
  toggleBtn.textContent = isFullscreen ? 'Exit fullscreen' : 'Fullscreen';
  toggleBtn.setAttribute('aria-pressed', String(isFullscreen));
  document.body.classList.toggle('is-fullscreen', isFullscreen);
}

document.addEventListener('fullscreenchange', syncFullscreenUI);
document.addEventListener('fullscreenerror', () =&gt; {
  console.warn('Could not enter fullscreen.');
});
</code></pre>
<p>This single listener covers every path in and out of fullscreen, including the ones you didn't initiate. It's also the right place to pause an animation, resume a game loop, or (as you'll see later) release a wake lock.</p>
<h2 id="heading-styling-fullscreen-content">Styling Fullscreen Content</h2>
<p>CSS gives you two hooks for fullscreen state.</p>
<p>The <code>:fullscreen</code> pseudo-class matches the element that's currently fullscreen. Browsers apply a default stylesheet that stretches the element to the full viewport, but you'll usually want to control things like background and overflow yourself:</p>
<pre><code class="language-css">:fullscreen {
  background: #000;
  overflow: hidden;
  cursor: none; /* hide the pointer on an idle fullscreen surface */
}
</code></pre>
<p>That <code>cursor: none</code> line looks cosmetic until you put a pure black page on an OLED display: every pixel is off, and the mouse pointer is quite literally the only thing lit on the panel. Hiding it once the page is fullscreen is the difference between "the screen is off" and "the screen has a tiny white arrow in the middle of it."</p>
<p>The <code>::backdrop</code> pseudo-element is the layer painted <em>behind</em> the fullscreen element. It only matters when your fullscreen element doesn't cover the whole screen (for example, an element with a fixed aspect ratio), and it's how you control the letterboxing colour:</p>
<pre><code class="language-css">:fullscreen::backdrop {
  background: #000;
}
</code></pre>
<p>One practical tip: if you're fullscreening the whole page, set your background colour on <code>html</code>, not <code>body</code>. In fullscreen, <code>html</code> is the element being displayed, and on some browsers a <code>body</code>-only background leaves a thin strip of the wrong colour at the edges during the transition.</p>
<h2 id="heading-cross-browser-gotchas">Cross-Browser Gotchas</h2>
<p>The Fullscreen API has been standard in Chrome, Edge and Firefox for years. Safari is where the work is.</p>
<h3 id="heading-older-safari-needs-the-webkit-prefix">Older Safari Needs the <code>webkit</code> Prefix</h3>
<p>Safari only shipped the unprefixed API in version 16.4 (spring 2023) on macOS and iPadOS. Before that it used <code>webkitRequestFullscreen()</code>, <code>webkitExitFullscreen()</code>, <code>webkitFullscreenElement</code>, and a <code>webkitfullscreenchange</code> event. Unless you can ignore three-year-old Safari installs, a tiny compatibility layer is worth having:</p>
<pre><code class="language-javascript">const fs = {
  get element() {
    return document.fullscreenElement ?? document.webkitFullscreenElement ?? null;
  },
  get enabled() {
    return Boolean(document.fullscreenEnabled ?? document.webkitFullscreenEnabled);
  },
  request(el = document.documentElement) {
    if (el.requestFullscreen) return el.requestFullscreen();
    if (el.webkitRequestFullscreen) return Promise.resolve(el.webkitRequestFullscreen());
    return Promise.reject(new Error('Fullscreen not supported'));
  },
  exit() {
    if (document.exitFullscreen) return document.exitFullscreen();
    if (document.webkitExitFullscreen) return Promise.resolve(document.webkitExitFullscreen());
    return Promise.reject(new Error('Fullscreen not supported'));
  },
};

// Listen to both event names so old Safari stays in sync too.
['fullscreenchange', 'webkitfullscreenchange'].forEach((evt) =&gt;
  document.addEventListener(evt, syncFullscreenUI)
);
</code></pre>
<p>Now <code>fs.request()</code> and <code>fs.exit()</code> both return a Promise in every browser, and the rest of your code doesn't care which one it's running in. (You'll see <code>screenfull.js</code> recommended for this. It's a fine library, but it's been declared feature-complete and frozen, and the fifteen lines above are all it was ever doing for you.)</p>
<h3 id="heading-iphone-safari-doesnt-support-element-fullscreen-at-all">iPhone Safari Doesn't Support Element Fullscreen at All</h3>
<p>This is the gotcha that catches everyone. As of the time of writing, <strong>Safari on iPhone doesn't support the Fullscreen API for arbitrary elements</strong> – only for <code>&lt;video&gt;</code>. It works on iPad and on the Mac, but on the phone <code>document.fullscreenEnabled</code> is <code>false</code> and <code>requestFullscreen</code> doesn't exist on a <code>&lt;div&gt;</code>. Chrome and Firefox on iOS inherit the same limitation because they're required to use Safari's engine.</p>
<p>You have two realistic options:</p>
<ol>
<li><p><strong>A "pseudo-fullscreen" fallback:</strong> Position your element with <code>position: fixed; inset: 0</code> and hide your own chrome. You won't get rid of Safari's address bar, but it collapses on scroll, and for most tools this is good enough.</p>
</li>
<li><p><strong>Suggest "Add to Home Screen":</strong> A web app launched from the home screen with <code>"display": "standalone"</code> in its manifest runs without any browser UI. This is the only way to get truly edge-to-edge content on an iPhone today.</p>
</li>
</ol>
<p>Whichever you choose, don't rely on feature detection alone. <code>document.fullscreenEnabled</code> tells you whether the API <em>exists</em>, but the request can still be rejected at runtime (inside an iframe without the <code>allowfullscreen</code> attribute, on a page a browser policy has locked down, or simply because a browser you haven't tested has its own opinion).</p>
<p>The pattern that has held up for me is to try the real API and treat a rejection as the trigger for the CSS fallback:</p>
<pre><code class="language-javascript">function enterFullscreen(el) {
  if (fs.enabled) {
    return fs.request(el).catch(() =&gt; {
      // The API exists but refused – degrade to a fixed overlay.
      document.body.classList.add('pseudo-fullscreen');
    });
  }
  document.body.classList.add('pseudo-fullscreen');
  return Promise.resolve();
}
</code></pre>
<p>On phones, I go one step further and don't call the API at all, even on Android where it technically works. Between the address bar reappearing on scroll and the system's swipe gestures, a fixed overlay behaves more predictably than real fullscreen on a small touch screen. It's also the same code path that iPhone Safari forces you into anyway. One less branch to test.</p>
<p>You can check the current support table on <a href="https://caniuse.com/fullscreen">Can I use</a> before deciding how much of this you need.</p>
<h2 id="heading-keeping-the-screen-awake-with-the-wake-lock-api">Keeping the Screen Awake with the Wake Lock API</h2>
<p>You've built a beautiful fullscreen experience. The user leans back to watch it… and ninety seconds later the screen dims and locks, because the operating system saw no input and assumed nobody was there.</p>
<p>For a video element, the browser handles this for you. For anything else (like a countdown, slideshow, canvas animation, recipe, or sheet-music page), you need the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API">Screen Wake Lock API</a>. It landed in every major browser in 2024 (Chrome 84+, Safari 16.4+, Firefox 126+) and became part of <a href="https://web.dev/blog/screen-wake-lock-supported-in-all-browsers">Baseline</a> in 2025, so you can use it today without a polyfill.</p>
<p>The API is a single call, and it's Promise-based:</p>
<pre><code class="language-javascript">let wakeLock = null;

async function requestWakeLock() {
  if (!('wakeLock' in navigator)) return; // unsupported – fail silently

  try {
    wakeLock = await navigator.wakeLock.request('screen');
    wakeLock.addEventListener('release', () =&gt; {
      // The OS or browser released it (tab hidden, battery saver, etc.)
      wakeLock = null;
    });
  } catch (err) {
    // Rejected – most often because the device is in a power-saving mode.
    console.warn(`Wake lock failed: ${err.name} – ${err.message}`);
  }
}

async function releaseWakeLock() {
  if (wakeLock) {
    await wakeLock.release();
    wakeLock = null;
  }
}
</code></pre>
<p>Three rules to know:</p>
<ol>
<li><p><strong>It requires a secure context.</strong> The API is only exposed over HTTPS (and <code>localhost</code>).</p>
</li>
<li><p><strong>The browser releases the lock automatically when the page is hidden</strong>. If the user switches tabs, minimises the window, or locks the phone, when the page becomes visible again, the lock does <em>not</em> come back on its own. You have to re-request it:</p>
</li>
</ol>
<pre><code class="language-javascript">document.addEventListener('visibilitychange', () =&gt; {
  if (document.visibilityState === 'visible' &amp;&amp; shouldStayAwake()) {
    requestWakeLock();
  }
});
</code></pre>
<ol>
<li><strong>Be a good citizen.</strong> A wake lock drains batteries. Only hold it while it's actually useful, like while something is playing, running, or being displayed. Release it the moment that stops.</li>
</ol>
<p>The nice part is that the Fullscreen API already gives you a perfect signal for "something is being displayed": <code>fullscreenchange</code>. When the user enters fullscreen, then request the lock. When they leave fullscreen, release it.</p>
<p>Fullscreen isn't the only good signal, though, and it helps to think of the wake lock as attached to <em>an activity</em> rather than to a display mode. On a countdown timer, I request the lock when the countdown starts and release it when it reaches zero or is paused (whether or not the page is fullscreen), because nobody wants a timer that goes dark at the 90-second mark. On a rain-sounds player, the lock follows the audio: it's acquired on play and released on pause or when the sleep timer fades the sound out.</p>
<p>The mechanics are identical: only the "should the screen stay awake right now?" question changes.</p>
<h2 id="heading-putting-it-all-together">Putting It All Together</h2>
<p>Here's the whole pattern in one place: a fullscreen toggle with a keyboard shortcut, a wake lock that follows fullscreen state, UI that stays in sync no matter how the user leaves, and a fallback for browsers that can't do it.</p>
<pre><code class="language-javascript">const toggleBtn = document.getElementById('fs-toggle');
let wakeLock = null;

/* ---------- Fullscreen compatibility layer ---------- */
const fs = {
  get element() {
    return document.fullscreenElement ?? document.webkitFullscreenElement ?? null;
  },
  get enabled() {
    return Boolean(document.fullscreenEnabled ?? document.webkitFullscreenEnabled);
  },
  request(el = document.documentElement) {
    if (el.requestFullscreen) return el.requestFullscreen();
    if (el.webkitRequestFullscreen) return Promise.resolve(el.webkitRequestFullscreen());
    return Promise.reject(new Error('Fullscreen not supported'));
  },
  exit() {
    if (document.exitFullscreen) return document.exitFullscreen();
    if (document.webkitExitFullscreen) return Promise.resolve(document.webkitExitFullscreen());
    return Promise.reject(new Error('Fullscreen not supported'));
  },
};

/* ---------- Wake lock ---------- */
async function requestWakeLock() {
  if (!('wakeLock' in navigator) || wakeLock) return;
  try {
    wakeLock = await navigator.wakeLock.request('screen');
    wakeLock.addEventListener('release', () =&gt; { wakeLock = null; });
  } catch (err) {
    console.warn(`Wake lock failed: ${err.name}`);
  }
}

async function releaseWakeLock() {
  if (!wakeLock) return;
  await wakeLock.release();
  wakeLock = null;
}

/* ---------- Toggle ---------- */
async function toggleFullscreen() {
  const pseudo = document.body.classList.contains('pseudo-fullscreen');
  try {
    if (pseudo) {
      document.body.classList.remove('pseudo-fullscreen');
      onFullscreenChange();
    } else if (!fs.element) {
      await fs.request();
    } else {
      await fs.exit();
    }
  } catch (err) {
    // The API refused (iframe policy, unsupported platform, etc.) – degrade gracefully.
    document.body.classList.add('pseudo-fullscreen');
    onFullscreenChange();
  }
}

/* ---------- Keep everything in sync ---------- */
function onFullscreenChange() {
  const active = Boolean(fs.element) || document.body.classList.contains('pseudo-fullscreen');
  toggleBtn.textContent = active ? 'Exit fullscreen' : 'Fullscreen';
  toggleBtn.setAttribute('aria-pressed', String(active));
  document.body.classList.toggle('is-fullscreen', active);

  // The screen should stay awake exactly as long as we're fullscreen.
  if (active) requestWakeLock(); else releaseWakeLock();
}

['fullscreenchange', 'webkitfullscreenchange'].forEach((evt) =&gt;
  document.addEventListener(evt, onFullscreenChange)
);

// Re-acquire the lock if the tab was hidden and came back while fullscreen.
document.addEventListener('visibilitychange', () =&gt; {
  if (document.visibilityState === 'visible' &amp;&amp; fs.element) requestWakeLock();
});

/* ---------- Wiring ---------- */
toggleBtn.addEventListener('click', toggleFullscreen);
document.addEventListener('keydown', (e) =&gt; {
  if (e.target.matches('input, textarea, [contenteditable]')) return;
  if (e.key === 'f' || e.key === 'F') { e.preventDefault(); toggleFullscreen(); }
});
// Unsupported platforms (iPhone Safari) simply take the catch branch above
// on the first click and land in pseudo-fullscreen – no user-agent sniffing needed.
// One more mobile quirk: rotating the device changes the viewport *after*
// the event fires, so re-measure your canvas or layout a beat later.
const resizeStage = () =&gt; { /* re-measure whatever fills the screen */ };
window.addEventListener('orientationchange', () =&gt; setTimeout(resizeStage, 100));
</code></pre>
<p>And the matching CSS:</p>
<pre><code class="language-css">html { background: #000; }

:fullscreen { overflow: hidden; cursor: none; }
:fullscreen::backdrop { background: #000; }

/* Fallback for browsers without element fullscreen */
body.pseudo-fullscreen .app { position: fixed; inset: 0; }
body.pseudo-fullscreen .chrome { display: none; }
</code></pre>
<p>Roughly sixty lines, and it's the same core I run in production: I log every fullscreen entry as an analytics event, because on a screen-tool site that transition <em>is</em> the conversion, and this exact code is what fires it.</p>
<p>It powers, for instance, the <a href="https://blankscreen.io/black-screen">black screen page on blankscreen.io</a> – a page whose entire job is to turn a display pure black, go fullscreen on a click or an <code>F</code> key, hide the pointer, stay awake for as long as it's on screen, and get out of the way cleanly on <code>Esc</code>. If you open it on an iPhone, you'll see the pseudo-fullscreen fallback from above doing its thing instead.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a60e4e5f99e7bbad25386f1/dea1eb4a-7862-46f8-a746-86550515820a.png" alt="A pure black web page showing a 'Click for fullscreen' prompt with hints to press F to enter and Esc to exit fullscreen" style="display: block;" width="600" height="400" loading="lazy">

<p>The same pattern in the wild: one click or the F key, the whole display goes black, and the screen stays awake for as long as it's showing. Esc brings the browser back.</p>
<p>Once you have this pattern, adding it to a timer, a slideshow, or a canvas experiment is a matter of dropping in the element you want to fill the screen.</p>
<h2 id="heading-a-quick-gotcha-checklist">A Quick Gotcha Checklist</h2>
<p>Before you ship, run through this list. Every item is something I've been bitten by at least once:</p>
<ul>
<li><p><strong>User gesture required:</strong> No fullscreen on load, on timer or after an <code>await</code> that takes a while.</p>
</li>
<li><p><strong>Don't track state yourself:</strong> Read <code>document.fullscreenElement</code> and listen for <code>fullscreenchange</code>.</p>
</li>
<li><p><strong>You can't block</strong><code>Esc</code><strong>:</strong> Design for the user leaving at any moment.</p>
</li>
<li><p><strong>Old Safari wants</strong> <code>webkit</code> <strong>prefixes</strong> for the methods <em>and</em> the event name.</p>
</li>
<li><p><strong>iPhone Safari has no element fullscreen:</strong> Feature-detect with <code>document.fullscreenEnabled</code>, and treat a runtime rejection as a fallback trigger, not just a console warning.</p>
</li>
<li><p><strong>Check your shortcut against the page's own keys:</strong> If the app consumes letters, let the shortcut only <em>enter</em> fullscreen and leave exiting to <code>Esc</code>.</p>
</li>
<li><p><strong>Make the fullscreen surface keyboard-reachable</strong> (<code>tabindex="0"</code>, <code>Enter</code>/<code>Space</code>).</p>
</li>
<li><p><strong>Wake locks need HTTPS:</strong> Can be rejected in battery-saver mode, and they're dropped when the page is hidden. Re-request on <code>visibilitychange</code>.</p>
</li>
<li><p><strong>Release the wake lock</strong> as soon as the reason for it is gone.</p>
</li>
<li><p><strong>Put the background on</strong> <code>html</code>, not just <code>body</code>, when fullscreening the page.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The Fullscreen API is one of those browser features that looks like a one-liner and turns out to have a personality.</p>
<p>The core (<code>requestFullscreen()</code>, <code>exitFullscreen()</code>, <code>fullscreenElement</code>, and the <code>fullscreenchange</code> event) really is simple. The craft is in respecting the user-gesture rule, keeping your UI honest about the real state, handling Safari's history, and remembering that "fullscreen" is only half of the job if the screen goes dark two minutes later. The Wake Lock API closes that gap, and pairing the two through <code>fullscreenchange</code> keeps both of them tidy.</p>
<p>Take the sixty-line pattern above, drop your own element into it, and you have a production-ready fullscreen experience for anything from a presentation to a game.</p>
<p>Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How ORMs Still Let SQL Injection Through (and How to Close the Gaps) ]]>
                </title>
                <description>
                    <![CDATA[ A lot of developers assume that once they're on an ORM, SQL injection stops being their problem. But it doesn't disappear. It just relocates. ORMs like Sequelize, Prisma, TypeORM, and Knex parameteriz ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-orms-still-let-sql-injection-through-and-how-to-close-the-gaps/</link>
                <guid isPermaLink="false">6abb5c6f814bf2c63668f9fc</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SQL ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ node ]]>
                    </category>
                
                    <category>
                        <![CDATA[ orm ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Hackita ]]>
                </dc:creator>
                <pubDate>Tue, 29 Sep 2026 06:36:31 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9e1f15e5-de00-4533-8349-70620154f7ef.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A lot of developers assume that once they're on an ORM, SQL injection stops being their problem. But it doesn't disappear. It just relocates.</p>
<p>ORMs like Sequelize, Prisma, TypeORM, and Knex parameterize the queries they build for you automatically. That part works.</p>
<p>The problem shows up in the parts of your app where you step outside that safe path: a raw query the ORM's query builder can't express cleanly, a dynamic sort column, or a single helper function reached for without a second thought.</p>
<p>Those are exactly the places <a href="https://hackita.it/articoli/hacker/">attackers go looking first</a>, and they're easy for developers to miss precisely because the rest of the codebase feels protected.</p>
<p>Four of those patterns are the focus here. Each one gets a working example that triggers the bug, a breakdown of why it's exploitable, and a rewritten version that closes it. I also include notes on exactly what moved and why.</p>
<p>You don't need a security background to follow along. You just need enough comfort with Node.js, SQL, and at least one ORM to read the code.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>The examples assume you're comfortable with:</p>
<ul>
<li><p>The basics of Node.js and Express</p>
</li>
<li><p>Writing basic SQL queries</p>
</li>
<li><p>Basic use of an ORM (all code examples use Sequelize, but the patterns apply to Prisma, TypeORM, Knex, and others too)</p>
</li>
<li><p>How an HTTP request/response cycle works</p>
</li>
</ul>
<p><strong>Note on code examples</strong>: Throughout this guide, <code>sequelize</code> is assumed to already be an initialized Sequelize instance, with <code>QueryTypes</code> and <code>Op</code> imported from <code>'sequelize'</code> alongside it, and <code>User</code>/<code>Product</code> are Sequelize models defined elsewhere in the app. Examples use Sequelize v6+ syntax (the current major version) since some of the APIs referenced here changed between major versions. You can adapt the syntax to the version and ORM you use.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-raw-query-escape-hatches">1. Raw Query Escape Hatches</a></p>
</li>
<li><p><a href="#heading-2-identifiers-that-cant-be-parameterized">2. Identifiers That Can't Be Parameterized</a></p>
</li>
<li><p><a href="#heading-3-second-order-injection-through-stored-data">3. Second-Order Injection Through Stored Data</a></p>
</li>
<li><p><a href="#heading-4-raw-sql-smuggled-into-normal-orm-calls">4. Raw SQL Smuggled Into Normal ORM Calls</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-1-raw-query-escape-hatches">1. Raw Query Escape Hatches</h2>
<p>Every major ORM ships an "escape hatch" for queries the query builder can't express cleanly: <code>sequelize.query()</code>, Prisma's <code>$queryRawUnsafe</code>, or TypeORM's <code>query()</code>. They exist for good reasons, like complex joins, window functions, and vendor-specific SQL.</p>
<p>The trouble starts when developers treat that escape hatch like the rest of the ORM, and interpolate user input directly into the string it builds.</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 filtered report endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/reports', async (req, res) =&gt; {
  const { region } = req.query;
  const results = await sequelize.query(
    `SELECT * FROM sales WHERE region = '${region}'`,
    { type: QueryTypes.SELECT }
  );
  res.json(results);
});
</code></pre>
<p>The query builder never sees this string. It's handed straight to the database driver exactly as written.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>A request like <code>?region=' OR '1'='1</code> turns the query into <code>SELECT * FROM sales WHERE region = '' OR '1'='1'</code>, which is always true. Every row in the table comes back, regardless of region.</p>
<p>Worse, because this code lives inside an ORM-based project, it often doesn't get the same scrutiny a raw <code>mysql.query()</code> call would in a non-ORM codebase. Reviewers assume the ORM already handled it.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Pass values through the replacement/binding mechanism the raw-query API already provides, instead of building the string yourself:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/reports', async (req, res) =&gt; {
  const { region } = req.query;
  const results = await sequelize.query(
    'SELECT * FROM sales WHERE region = :region',
    {
      replacements: { region },
      type: QueryTypes.SELECT
    }
  );
  res.json(results);
});
</code></pre>
<p>The fix isn't avoiding raw queries entirely, because sometimes you genuinely need them. It's never building the SQL string by hand when a binding mechanism is sitting right there.</p>
<p><strong>Never concatenate user input into a raw query string. Use the replacement/binding API your raw-query method already provides.</strong></p>
<h2 id="heading-2-identifiers-that-cant-be-parameterized">2. Identifiers That Can't Be Parameterized</h2>
<p>Parameterized queries protect <em>values</em>. They don't protect <em>identifiers</em> like table names, column names, or <code>ORDER BY</code> direction. Those have to be part of the SQL string itself.</p>
<p>That's why a dynamic sort feature is one of the most common places injection sneaks back into otherwise well-written ORM code.</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 sortable list endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/users', async (req, res) =&gt; {
  const { sortBy = 'created_at' } = req.query;
  const users = await sequelize.query(
    `SELECT * FROM users ORDER BY ${sortBy}`,
    { type: QueryTypes.SELECT }
  );
  res.json(users);
});
</code></pre>
<p><code>sortBy</code> goes straight from the query string into the <code>ORDER BY</code> clause, with nothing in between.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p><code>sortBy</code> looks like a harmless UI convenience until someone sends <code>created_at; DROP TABLE users; --</code>. Whether that exact payload runs depends on your database driver: Postgres's <code>pg</code> driver executes stacked statements like this by default, while MySQL's <code>mysql2</code> blocks them unless <code>multipleStatements: true</code> is explicitly set.</p>
<p>Either way, the underlying problem is the same: you have arbitrary SQL sitting in a position that was only ever meant to hold a column name.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Since identifiers can't be bound as parameters, the only safe option is an allowlist:</p>
<pre><code class="language-javascript">// Express.js - Secure
const ALLOWED_SORT_COLUMNS = ['created_at', 'name', 'email'];

app.get('/users', async (req, res) =&gt; {
  const { sortBy = 'created_at' } = req.query;
  const column = ALLOWED_SORT_COLUMNS.includes(sortBy) ? sortBy : 'created_at';

  const users = await sequelize.query(
    `SELECT * FROM users ORDER BY ${column}`,
    { type: QueryTypes.SELECT }
  );
  res.json(users);
});
</code></pre>
<p>Never pass user input through to an identifier position, even after "sanitizing" it first. Sanitization for identifiers is much easier to get wrong than for values.</p>
<p><strong>Identifiers can't be parameterized. If user input decides a column or table name, allowlist it, don't sanitize it.</strong></p>
<h2 id="heading-3-second-order-injection-through-stored-data">3. Second-Order Injection Through Stored Data</h2>
<p>This one catches teams off guard because the input <em>was</em> parameterized...the first time it was written to the database.</p>
<p>The injection happens later, when that already-stored value gets reused inside a different, unparameterized query.</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 registration flow, and elsewhere in the codebase, an admin search feature:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable

// Step 1: user registration, correctly parameterized
app.post('/register', async (req, res) =&gt; {
  await User.create({ username: req.body.username });
  res.json({ success: true });
});

// Step 2: somewhere else in the codebase, an admin search feature
app.get('/admin/search', async (req, res) =&gt; {
  const user = await User.findByPk(req.params.id);
  const results = await sequelize.query(
    `SELECT * FROM audit_log WHERE actor = '${user.username}'`,
    { type: QueryTypes.SELECT }
  );
  res.json(results);
});
</code></pre>
<p>Step 1 is completely safe on its own. Step 2 is where things go wrong.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>A username like <code>admin' OR '1'='1</code> passes through step 1 without any issue. Sequelize's <code>create()</code> parameterizes it, so it's stored exactly as typed. It sits in the database looking completely normal.</p>
<p>The payload only fires when step 2 pulls that value back out and drops it straight into a raw query string. "It's already in our own database" is not the same thing as "it's safe". It's still attacker-controlled if it originated from user input anywhere upstream.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Same fix as pattern #1. You bind the value instead of concatenating it:</p>
<pre><code class="language-javascript">// Express.js - Secure

// Step 1 (registration) is unchanged — it was already parameterized

app.get('/admin/search', async (req, res) =&gt; {
  const user = await User.findByPk(req.params.id);
  const results = await sequelize.query(
    'SELECT * FROM audit_log WHERE actor = :actor',
    {
      replacements: { actor: user.username },
      type: QueryTypes.SELECT
    }
  );
  res.json(results);
});
</code></pre>
<p>The bug here is harder to spot than pattern #1, because the tainted data crosses a database round-trip before it becomes dangerous.</p>
<p><strong>Data from your own database isn't automatically safe. If it originated from user input anywhere upstream, it still needs to be parameterized everywhere it's used.</strong></p>
<h2 id="heading-4-raw-sql-smuggled-into-normal-orm-calls">4. Raw SQL Smuggled Into Normal ORM Calls</h2>
<p>Pattern #1 covered the <em>obvious</em> raw-query escape hatch, a method you reach for on purpose when you need real SQL. This one is sneakier: injection hiding inside a call that looks completely ORM-managed. It's not raw SQL, right? It's just one helper function among many.</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 filtered product listing:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/products', async (req, res) =&gt; {
  const { minPrice } = req.query;
  const products = await Product.findAll({
    where: sequelize.literal(`price &gt; ${minPrice}`)
  });
  res.json(products);
});
</code></pre>
<p><code>Product.findAll()</code> looks like a fully safe, parameterized ORM call...right up until <code>sequelize.literal()</code> shows up inside it.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p><code>literal()</code> tells Sequelize "don't touch this, insert it into the SQL exactly as written." Anything interpolated into it is exactly as injectable as pattern #1, just wearing a normal-looking ORM method as a disguise.</p>
<p>Send <code>?minPrice=0 OR 1=1</code> and the <code>WHERE</code> clause becomes unconditionally true: every row comes back, price filter or not. No stacked statement needed this time. It's a single boolean expression, so it works the same regardless of database driver.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Stop reaching for <code>literal()</code>. Sequelize's own operator API already covers this case:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/products', async (req, res) =&gt; {
  const minPrice = Number(req.query.minPrice);

  if (!Number.isFinite(minPrice)) {
    return res.status(400).json({ error: 'minPrice must be a number' });
  }

  const products = await Product.findAll({
    where: { price: { [Op.gt]: minPrice } }
  });
  res.json(products);
});
</code></pre>
<p><code>Op.gt</code>, <code>Op.between</code>, <code>Op.in</code>, and the rest of Sequelize's operator API already cover the vast majority of cases people reach for <code>literal()</code> for, and they parameterize correctly by default. Validating that <code>minPrice</code> is actually a number, before it ever reaches the query, closes the gap even if a future refactor reintroduces <code>literal()</code> somewhere else.</p>
<p><strong>Grep for</strong> <code>literal(</code><strong>,</strong> <code>fn(</code><strong>, and your ORM's other raw escape hatches everywhere they appear in the codebase, not just inside the queries that are clearly "raw."</strong></p>
<h2 id="heading-summary">Summary</h2>
<p>For a quick recap, here's how the four patterns line up:</p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Root Cause</th>
<th>Core Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Raw Query Escape Hatches</td>
<td>User input concatenated into a raw query string</td>
<td>Bind values via the replacement/parameter API</td>
</tr>
<tr>
<td>Identifier Injection</td>
<td>Column/table/sort input can't be parameterized</td>
<td>Allowlist allowed identifiers explicitly</td>
</tr>
<tr>
<td>Second-Order Injection</td>
<td>Stored data trusted because it was parameterized once</td>
<td>Parameterize every query, including ones using your own stored data</td>
</tr>
<tr>
<td>Literal Injection</td>
<td>Raw SQL smuggled into an otherwise ORM-managed call</td>
<td>Avoid <code>literal()</code>/<code>raw()</code>, use the ORM's operator API instead</td>
</tr>
</tbody></table>
<p>None of these four bugs takes a skilled attacker to find. Each one is just a value that never made it into the ORM's parameterization path.</p>
<p>What's worth making part of your workflow: parameterize values, allowlist identifiers, treat data pulled from your own database as no safer than a fresh request, and treat <code>literal()</code>/<code>raw()</code> calls as exactly as dangerous as a dedicated raw-query method (no matter how safe the surrounding code looks).</p>
<p>Fixing these gaps is only half the picture. Knowing how someone actually goes looking for them (and chains them together during a real assessment) is the half most developers never see.</p>
<p>If you're curious about that offensive perspective, this <a href="https://hackita.it/articoli/sql-injection-orm/">deep dive on SQL injection through ORMs</a> walks through exactly how these four patterns get exploited step by step in a live penetration test.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Bulk Image Compressor Tool with HTML, CSS, and JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ High-resolution images look great, but they can significantly slow down page load times and consume massive amounts of storage. While backend compression tools are common, building a client-side image ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-bulk-image-compressor-tool-with-html-css-and-javascript/</link>
                <guid isPermaLink="false">6aad5cf52b32e1bee69c90a8</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bansidhar Kadiya ]]>
                </dc:creator>
                <pubDate>Fri, 18 Sep 2026 15:47:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f0d7a939-276b-4eb9-a380-6f376997dbe6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>High-resolution images look great, but they can significantly slow down page load times and consume massive amounts of storage.</p>
<p>While backend compression tools are common, building a client-side image compressor offers a massive advantage: privacy. When you process images directly in the browser, no user data ever touches a server.</p>
<p>In this tutorial, you'll learn how to build a fully functional, browser-based bulk image compressor. You'll use the HTML5 Canvas API to reduce image file sizes and integrate the JSZip library to package multiple compressed files into a single, convenient ZIP download.</p>
<p>To make this project highly practical, you'll structure the code as an embeddable widget. By omitting standard HTML boilerplate tags, you can easily drop this snippet directly into a WordPress Custom HTML block or any other CMS without causing layout conflicts.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should have a basic understanding of:</p>
<ul>
<li><p><strong>HTML &amp; CSS:</strong> Structuring a UI and creating interactive hover/drag states.</p>
</li>
<li><p><strong>JavaScript Promises:</strong> Handling asynchronous operations like file reading and ZIP generation.</p>
</li>
<li><p><strong>The Canvas API:</strong> Understanding how browsers can draw and manipulate image data natively.</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-step-1-build-the-html-structure">Step 1: Build the HTML Structure</a></p>
<ul>
<li><a href="#heading-understanding-the-html">Understanding the HTML</a></li>
</ul>
</li>
<li><p><a href="#heading-step-2-style-the-interface-with-css">Step 2: Style the Interface with CSS</a></p>
<ul>
<li><a href="#heading-understanding-the-css">Understanding the CSS</a></li>
</ul>
</li>
<li><p><a href="#heading-step-3-add-the-javascript-logic">Step 3: Add the JavaScript Logic</a></p>
<ul>
<li><a href="#heading-understanding-the-javascript">Understanding the JavaScript</a></li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-step-1-build-the-html-structure">Step 1: Build the HTML Structure</h2>
<p>First, you need to create the visual interface. You'll build a drag-and-drop zone, an invisible file input, and a hidden action panel that appears once the user selects their images.</p>
<p>Add the following code to your file. Notice that you're including the JSZip CDN link right at the top so your script can access it later.</p>
<pre><code class="language-HTML">&lt;!-- JSZip Library for Bulk Downloading --&gt;
&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"&gt;&lt;/script&gt;

&lt;div class="ic-main-wrapper"&gt;
    &lt;div class="ic-title"&gt;Image Compressor&lt;/div&gt;
    
    &lt;!-- The Drag and Drop Zone --&gt;
    &lt;div class="ic-dropzone-area" id="icDropzone"&gt;
        &lt;input type="file" id="icFileInput" multiple accept="image/jpeg, image/png, image/webp, image/gif, image/bmp, image/tiff, image/avif" style="display: none;"&gt;
        
        &lt;div class="ic-icon-box"&gt;
            &lt;svg viewBox="0 0 24 24"&gt;
                &lt;path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h14l-4-5z"/&gt;
            &lt;/svg&gt;
        &lt;/div&gt;
        
        &lt;div class="ic-primary-text"&gt;
            Drop images here or &lt;span class="ic-browse-link" id="icBrowseBtn"&gt;click to browse&lt;/span&gt;
        &lt;/div&gt;
        &lt;div class="ic-secondary-text"&gt;
            Multiple images supported — bulk compress &amp; download as ZIP
        &lt;/div&gt;
        
        &lt;div class="ic-format-tags"&gt;
            &lt;span&gt;JPEG&lt;/span&gt;&lt;span&gt;PNG&lt;/span&gt;&lt;span&gt;WebP&lt;/span&gt;&lt;span&gt;GIF&lt;/span&gt;&lt;span&gt;BMP&lt;/span&gt;&lt;span&gt;TIFF&lt;/span&gt;&lt;span&gt;AVIF&lt;/span&gt;
        &lt;/div&gt;
    &lt;/div&gt;
    
    &lt;!-- Action Panel (Hidden by default) --&gt;
    &lt;div class="ic-action-panel" id="icActionPanel"&gt;
        &lt;div id="icStatusText" class="ic-status-msg"&gt;0 images selected&lt;/div&gt;
        &lt;button class="ic-btn-primary" id="icCompressBtn"&gt;Compress &amp; Download ZIP&lt;/button&gt;
    &lt;/div&gt;
&lt;/div&gt;
</code></pre>
<h3 id="heading-understanding-the-html">Understanding the HTML:</h3>
<ul>
<li><p><code>accept="..."</code> <strong>attribute:</strong> This restricts the hidden <code>&lt;input type="file"&gt;</code> to only accept image formats, preventing users from accidentally uploading PDFs or text documents.</p>
</li>
<li><p><strong>Embeddable Structure:</strong> Because this markup relies on a single <code>.ic-main-wrapper</code> container rather than full <code>&lt;html&gt;</code> and <code>&lt;body&gt;</code> tags, you can safely embed it into existing web pages without breaking the parent theme.</p>
</li>
</ul>
<h2 id="heading-step-2-style-the-interface-with-css">Step 2: Style the Interface with CSS</h2>
<p>Next, you'll apply styling to make the tool look professional and responsive. You'll use a clean, modern aesthetic with a specific brand accent color (<code>#1A73E8</code>) to highlight interactive elements.</p>
<p>Add this <code>&lt;style&gt;</code> block right above your HTML:</p>
<pre><code class="language-CSS">&lt;style&gt;
.ic-main-wrapper {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    max-width: 850px;
    margin: 20px auto;
    background: #ffffff;
    border-radius: 12px;
    padding: 30px;
    box-shadow: 0 4px 20px rgba(0,0,0,0.04);
}

.ic-title {
    font-size: 2.2rem;
    font-weight: 700;
    color: #0f172a;
    text-align: center;
    margin-bottom: 30px;
}

.ic-dropzone-area {
    border: 1.5px dashed #cbd5e1;
    border-radius: 12px;
    padding: 60px 20px;
    text-align: center;
    background-color: #f8fafc;
    transition: all 0.3s ease;
}

/* Active Drag State */
.ic-dropzone-area.dragover {
    border-color: #1A73E8;
    background-color: #f1f5f9;
}

.ic-icon-box {
    width: 64px;
    height: 64px;
    background-color: #e8f0fe;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    margin: 0 auto 24px;
    color: #1A73E8;
}

.ic-icon-box svg {
    width: 28px;
    height: 28px;
    fill: currentColor;
}

.ic-primary-text {
    font-size: 1.15rem;
    color: #0f172a;
    font-weight: 500;
    margin-bottom: 12px;
}

.ic-browse-link {
    color: #1A73E8;
    cursor: pointer;
    text-decoration: none;
    font-weight: 500;
}

.ic-browse-link:hover {
    text-decoration: underline;
}

.ic-secondary-text {
    font-size: 0.95rem;
    color: #64748b;
    margin-bottom: 24px;
}

.ic-format-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
    justify-content: center;
}

.ic-format-tags span {
    border: 1px solid #e2e8f0;
    background: #ffffff;
    color: #64748b;
    font-size: 0.8rem;
    padding: 6px 18px;
    border-radius: 20px;
}

.ic-action-panel {
    margin-top: 25px;
    display: none;
    text-align: center;
    background: #f8fafc;
    padding: 20px;
    border-radius: 8px;
    border: 1px solid #e2e8f0;
}

.ic-btn-primary {
    background: #1A73E8;
    color: #ffffff;
    border: none;
    padding: 12px 28px;
    border-radius: 6px;
    font-size: 1rem;
    cursor: pointer;
    font-weight: 500;
    transition: background 0.3s;
}

.ic-btn-primary:hover {
    background: #1557b0;
}

.ic-btn-primary:disabled {
    background: #94a3b8;
    cursor: not-allowed;
}

.ic-status-msg {
    margin-bottom: 15px;
    font-size: 0.95rem;
    color: #334155;
    font-weight: 500;
}
&lt;/style&gt;
</code></pre>
<h3 id="heading-understanding-the-css">Understanding the CSS:</h3>
<ul>
<li><p><strong>The</strong> <code>.dragover</code> <strong>Class:</strong> This class alters the border and background color of the dropzone. You'll use JavaScript to apply this class dynamically when a user hovers a file over the area, providing crucial visual feedback.</p>
</li>
<li><p><strong>Unique Prefixes:</strong> Notice how every class starts with <code>ic-</code> (Image Compressor). This acts as a CSS namespace, ensuring your tool's styles won't accidentally override or be overridden by other styles on your website.</p>
</li>
</ul>
<p>At this stage, your user interface is fully structured and styled. It will look like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/699c7b22cf5def0f6aaf982b/2e75b26b-e1ae-443b-a78f-6f9833a4774a.png" alt="2e75b26b-e1ae-443b-a78f-6f9833a4774a" style="display: block;" width="1432" height="723" loading="lazy">

<h2 id="heading-step-3-add-the-javascript-logic">Step 3: Add the JavaScript Logic</h2>
<p>Now comes the engine of the tool. The JavaScript will handle file selection, convert the images using the Canvas API to reduce their size, and package them into a ZIP file.</p>
<p>Add this <code>&lt;script&gt;</code> block below your HTML:</p>
<pre><code class="language-javascript">&lt;script&gt;
document.addEventListener('DOMContentLoaded', () =&gt; {
    const dropzone = document.getElementById('icDropzone');
    const fileInput = document.getElementById('icFileInput');
    const browseBtn = document.getElementById('icBrowseBtn');
    const actionPanel = document.getElementById('icActionPanel');
    const statusText = document.getElementById('icStatusText');
    const compressBtn = document.getElementById('icCompressBtn');
    
    let filesArray = [];

    // 1. Handle File Input and Drag &amp; Drop
    browseBtn.addEventListener('click', (e) =&gt; {
        e.stopPropagation();
        fileInput.click();
    });
    
    dropzone.addEventListener('dragover', (e) =&gt; {
        e.preventDefault();
        dropzone.classList.add('dragover');
    });
    
    dropzone.addEventListener('dragleave', () =&gt; {
        dropzone.classList.remove('dragover');
    });
    
    dropzone.addEventListener('drop', (e) =&gt; {
        e.preventDefault();
        dropzone.classList.remove('dragover');
        if (e.dataTransfer.files.length) {
            processSelectedFiles(e.dataTransfer.files);
        }
    });

    fileInput.addEventListener('change', () =&gt; {
        if (fileInput.files.length) {
            processSelectedFiles(fileInput.files);
        }
    });

    // 2. Validate and Display the Action Panel
    function processSelectedFiles(files) {
        filesArray = Array.from(files).filter(file =&gt; file.type.startsWith('image/'));
        if (filesArray.length &gt; 0) {
            actionPanel.style.display = 'block';
            statusText.innerText = `${filesArray.length} image(s) selected. Ready to compress.`;
        }
    }

    // 3. The Core Compression Engine
    function compressImage(file) {
        return new Promise((resolve) =&gt; {
            const reader = new FileReader();
            reader.onload = (event) =&gt; {
                const img = new Image();
                img.onload = () =&gt; {
                    const canvas = document.createElement('canvas');
                    canvas.width = img.width;
                    canvas.height = img.height;
                    const ctx = canvas.getContext('2d');
                    
                    // Draw the image onto the canvas
                    ctx.drawImage(img, 0, 0);
                    
                    // Force compatibility: Convert unusual formats to JPEG or WebP
                    let mimeType = file.type;
                    if (mimeType !== 'image/jpeg' &amp;&amp; mimeType !== 'image/webp') {
                        mimeType = 'image/jpeg';
                    }
                    
                    // Compress using the canvas.toBlob method at 70% quality (0.7)
                    canvas.toBlob((blob) =&gt; {
                        let finalName = file.name;
                        
                        // Ensure the file extension matches the new mimeType
                        if (mimeType === 'image/jpeg' &amp;&amp; !finalName.match(/\.(jpg|jpeg)$/i)) {
                            finalName = finalName.substring(0, finalName.lastIndexOf('.')) + '.jpg';
                        }
                        
                        resolve({ name: finalName, blob: blob });
                    }, mimeType, 0.7); 
                };
                img.src = event.target.result;
            };
            reader.readAsDataURL(file);
        });
    }

    // 4. Handle the Bulk ZIP Process
    compressBtn.addEventListener('click', async () =&gt; {
        if (filesArray.length === 0) return;
        
        compressBtn.disabled = true;
        
        try {
            const zip = new JSZip();
            const folder = zip.folder("compressed_images");
            
            // Loop through each file and await its compression
            for (let i = 0; i &lt; filesArray.length; i++) {
                statusText.innerText = `Compressing ${i + 1} of ${filesArray.length}...`;
                const compressedData = await compressImage(filesArray[i]);
                folder.file(compressedData.name, compressedData.blob);
            }
            
            statusText.innerText = 'Creating ZIP file...';
            
            // Generate and trigger the ZIP download
            const zipContent = await zip.generateAsync({ type: "blob" });
            const downloadLink = document.createElement('a');
            downloadLink.href = URL.createObjectURL(zipContent);
            downloadLink.download = "compressed_images.zip";
            downloadLink.click();
            
            statusText.innerText = 'Download successful!';
        } catch (error) {
            statusText.innerText = 'An error occurred during compression.';
            console.error(error);
        } finally {
            compressBtn.disabled = false;
        }
    });
});
&lt;/script&gt;
</code></pre>
<h3 id="heading-understanding-the-javascript">Understanding the JavaScript:</h3>
<ul>
<li><p><strong>Drag and drop events:</strong> The <code>dragover</code>, <code>dragleave</code>, and <code>drop</code> event listeners work together to capture files dragged from a user's desktop directly into the browser window.</p>
</li>
<li><p><strong>The canvas trick:</strong> Browsers can't magically compress files on their own. Instead, the <code>compressImage</code> function reads the image, draws it onto an invisible HTML5 <code>&lt;canvas&gt;</code>, and then uses the <code>canvas.toBlob()</code> method to export a new, optimized version of that image. The <code>0.7</code> argument sets the compression quality to 70%.</p>
</li>
<li><p><strong>Extension handling:</strong> Because the canvas exports images as either JPEG or WebP, the script actively checks and renames the file extensions (for example, changing a <code>.png</code> filename to <code>.jpg</code>) before packaging them.</p>
</li>
<li><p><strong>Asynchronous zipping:</strong> A <code>for</code> loop uses <code>await</code> to compress each image sequentially. Once complete, JSZip bundles the blobs into a folder and triggers a programmatic click event on a temporary anchor <code>&lt;a&gt;</code> tag to download the final <code>.zip</code> file.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've successfully built a fast, client-side bulk image compressor.</p>
<p>By leveraging the Canvas API alongside JSZip, you created a highly practical utility that saves users bandwidth and storage without compromising their privacy. Because the entire codebase is self-contained without HTML boilerplate, it's ready to be deployed instantly on almost any modern web platform.</p>
<p>If you want to see this codebase deployed in a live, production environment, you can test out the functionality over at this Live <a href="https://99tools.net/image-compressor/">Image Compressor Tool</a>. Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The Gamepad API Lies to You: A Practical Guide to Reading Controller Input in JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ The Gamepad API is one of the smallest browser APIs you'll ever use. Four properties, one function, and no permissions prompt. You can have a controller drawn on screen in about fifteen lines. Those f ]]>
                </description>
                <link>https://www.freecodecamp.org/news/gamepad-api-javascript-guide/</link>
                <guid isPermaLink="false">6a9f3d3144b92ecc2d3aca7a</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Browsers ]]>
                    </category>
                
                    <category>
                        <![CDATA[ APIs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Game Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ taimoor bamazai ]]>
                </dc:creator>
                <pubDate>Mon, 07 Sep 2026 22:39:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/25358bd5-321e-477c-8561-e783792bce9e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The Gamepad API is one of the smallest browser APIs you'll ever use. Four properties, one function, and no permissions prompt. You can have a controller drawn on screen in about fifteen lines.</p>
<p>Those fifteen lines will also quietly report that a broken controller is fine.</p>
<p>I found this out the slow way, building a browser-based controller tester. A user emailed to say the site told him his gamepad was healthy when the stick was visibly drifting in every game he owned. He was right. The browser had handed us zeros.</p>
<p>This article covers the parts of the Gamepad API that aren't in the spec docs and that cost me real debugging time: why you have to poll, why the values you get on page load aren't the values the hardware sent, why you can't tell what controller is plugged in, and how to tell a drifting analog stick apart from a person holding one.</p>
<p>All the code here runs in a browser console with a controller connected. Press a button first, or the API will pretend nothing is plugged in.</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-the-tester-that-doesnt-work">The Tester That Doesn't Work</a></p>
</li>
<li><p><a href="#heading-why-you-have-to-poll">Why You Have to Poll</a></p>
</li>
<li><p><a href="#heading-the-sanitization-rule">The Sanitization Rule</a></p>
</li>
<li><p><a href="#heading-you-cant-identify-the-hardware-either">You Can't Identify the Hardware, Either</a></p>
</li>
<li><p><a href="#heading-telling-drift-from-a-human-hand">Telling Drift from a Human Hand</a></p>
</li>
<li><p><a href="#heading-known-limits">Known Limits</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This is a hands-on guide. There's nothing to install and no build step, but a few things need to be true before the code below will do anything.</p>
<p><strong>What you should already know:</strong></p>
<ul>
<li><p>JavaScript at a working level: functions, arrays and array methods like <code>reduce</code> and <code>filter</code>, arrow functions, and destructuring.</p>
</li>
<li><p>What an animation frame loop is. Several of the examples run inside <code>requestAnimationFrame</code>.</p>
</li>
<li><p>How to open your browser's developer tools and paste code into the console.</p>
</li>
</ul>
<p>One section does a little vector arithmetic: the mean of a set of x and y samples and the length of that mean vector. If <code>Math.hypot(x, y)</code> makes sense to you, that section will too.</p>
<p><strong>What you need to have:</strong></p>
<ul>
<li><p>A desktop browser that supports the Gamepad API. Chrome, Edge, Firefox and Safari have all supported it since 2017, so whatever you have open is almost certainly fine.</p>
</li>
<li><p>A physical game controller, connected by USB or Bluetooth. There's no way to fake one in software, and none of the code below does anything useful without hardware attached.</p>
</li>
<li><p>Ideally, a controller you know is faulty, like one with stick drift if you have it. Several of the behaviours in this article only show up on broken hardware. A healthy controller will hide them from you.</p>
</li>
</ul>
<p>You don't need any frameworks or libraries, or npm install. Every block below is plain JavaScript that runs as written.</p>
<h2 id="heading-the-tester-that-doesnt-work">The Tester That Doesn't Work</h2>
<p>Here's the version almost everyone writes first. It's the version in most tutorials.</p>
<pre><code class="language-js">window.addEventListener("gamepadconnected", (e) =&gt; {
  const pad = navigator.getGamepads()[e.gamepad.index];
  console.log(pad.axes);    // [0, 0, 0, 0]
  console.log(pad.buttons.filter(b =&gt; b.pressed).length);   // 0
});
</code></pre>
<p>Plug in a controller with severe stick drift, one that pulls a character across the screen on its own in every game, and this prints <code>[0, 0, 0, 0]</code>.</p>
<p>There are two separate bugs in those five lines, and the second one is the interesting one.</p>
<h2 id="heading-why-you-have-to-poll">Why You Have to Poll</h2>
<p>The first bug is that there are no input events. <code>gamepadconnected</code> and <code>gamepaddisconnected</code> fire, and that's the entire event surface. There's no <code>gamepadaxischange</code> and no <code>gamepadbuttondown</code>. If you want to know what the sticks are doing, you have to ask, over and over, usually in <code>requestAnimationFrame</code>.</p>
<p>The second part of the same bug: you have to call <code>navigator.getGamepads()</code> again every single frame. It returns snapshots. Holding on to a <code>Gamepad</code> object and reading it later gets you the values from the moment you grabbed it, frozen, forever.</p>
<pre><code class="language-js">function loop() {
  const pads = navigator.getGamepads();     // re-read every frame, do not cache
  for (const pad of pads) {
    if (!pad) continue;                     // the array has empty slots, always guard
    render(pad.index, pad.axes, pad.buttons);
  }
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</code></pre>
<p>Two practical notes on that loop.</p>
<p>First, the array is sparse. <code>navigator.getGamepads()</code> returns a fixed length array with <code>null</code> in the slots that have nothing connected, so a plain <code>for...of</code> without the guard will throw on the first <code>null</code>.</p>
<p>Second, polling isn't free. A <code>requestAnimationFrame</code> loop that starts at page load and runs forever is real main thread work on a page that may have no controller connected at all and never will.</p>
<p>Here's a pattern that works well: idle at a low rate, something like 8 times a second with <code>setTimeout</code>, purely to notice a controller appearing, then switch to full <code>requestAnimationFrame</code> once one is actually connected, and drop back down when it disconnects. The API is cheap to sample, but sampling it 60 times a second on every page view for nothing is a waste you'll see in a performance profile.</p>
<h2 id="heading-the-sanitization-rule">The Sanitization Rule</h2>
<p>Now the part that is genuinely under-documented, and the reason the drifting controller reported zeros.</p>
<p>Chromium won't report an axis's real value until it has seen that axis at rest at least once.</p>
<p>Not until the user moves it. Until the browser observes it near zero.</p>
<p>The mechanism is in one file, <a href="https://github.com/chromium/chromium/blob/2ef21ead8cf5ba3ce6202d7e8cb5cb41450e605e/device/gamepad/gamepad_pad_state_provider.cc#L23">device/gamepad/gamepad_pad_state_provider.cc</a>. The browser keeps two bitfields per connected controller, an <code>axis_mask</code> and a <code>button_mask</code>. While an axis's bit is unset, its reported value is forced to <code>0.0</code>. The bit gets set the first time that axis reports a magnitude below a constant called <code>kMinAxisResetValue</code>, which is <code>0.1f</code>. From then on, real values flow through.</p>
<p>Buttons work the same way through <code>button_mask</code>, with a stricter test: the bit is set the first time the button reports as not pressed. A button that's held down as the page loads, or a trigger that a broken spring is holding halfway, reports <code>pressed: false</code> and <code>value: 0</code> until the browser sees it released once.</p>
<p>This isn't a bug, and it's worth understanding why it's there. The comment in the source explains it: a controller can report input when nobody is touching it, because of a hardware fault or because something heavy is leaning on a stick. Without this rule, that stray input would be treated as a user gesture, and the page would learn about a device the user never chose to reveal. So each axis and each button has to prove it can sit at rest before the browser will tell you anything about it.</p>
<p>Read the consequence carefully, because it's the opposite of what you would guess:</p>
<p><strong>The worse the drift, the longer the browser insists the controller is fine.</strong></p>
<p>A stick with a small offset will pass under <code>0.1</code> on some frame soon enough and unmask itself. A badly worn stick that never settles back inside that window stays masked indefinitely. The controller that most needs reporting is the one that reports nothing.</p>
<p>This also explains something that looks like magic in controller testers. Instructions like "move both sticks in a full circle" don't work because movement unlocks the axis. They work because a full circle passes through the centre on the way back.</p>
<p>Here is a demo you can paste into a console. Connect a controller, load the page, and don't touch the sticks. Then push the left stick to the edge and let it spring back.</p>
<pre><code class="language-js">const start = performance.now();
let woke = false;

requestAnimationFrame(function loop() {
  const pad = navigator.getGamepads()[0];
  if (pad &amp;&amp; !woke) {
    const [x, y] = pad.axes;
    if (x !== 0 || y !== 0) {
      woke = true;
      console.log(
        "left stick started reporting after",
        Math.round(performance.now() - start), "ms,",
        "first values:", x.toFixed(3), y.toFixed(3)
      );
    }
  }
  requestAnimationFrame(loop);
});
</code></pre>
<p>On a healthy controller sitting still, the axes unmask almost immediately, because a healthy stick rests at roughly zero. On a drifting one, nothing is logged until you send the stick through the centre yourself.</p>
<p>The practical rule that falls out of this: never draw a conclusion about hardware from the first frame after connection. Wait until you've seen each axis report a non zero value at least once, or ask the user to move the sticks, and only then trust what you're reading.</p>
<h2 id="heading-you-cant-identify-the-hardware-either">You Can't Identify the Hardware, Either</h2>
<p>The second surprise is smaller but it will bite you in the UI layer.</p>
<p>The spec gives you <code>pad.id</code>, a string the browser makes up. On Linux and often on macOS it contains a USB vendor and product ID in hex, and you can look the device up. On Windows, XInput devices (which is to say most Xbox style controllers) expose no vendor or product ID at all. The string looks like <code>"Xbox 360 Controller (XInput STANDARD GAMEPAD)"</code>, and a third party clone reports exactly the same thing as first party hardware.</p>
<p>macOS has its own version of this. A DualShock 4 connected to Chrome on macOS arrives as <code>"Wireless Controller (STANDARD GAMEPAD)"</code>. No vendor ID, no product ID, and a name generic enough that half a dozen unrelated controllers share it.</p>
<p>That last one cost me a real bug. Our glyph rendering keyed off a parsed <code>id</code> string, so every DualShock 4 on a Mac fell through to the generic fallback and drew Xbox-style button labels on a PlayStation controller. Every Mac user of that feature saw the wrong thing for months, and no Windows or Linux test would ever have caught it.</p>
<p>Branch on capability instead:</p>
<pre><code class="language-js">function describe(pad) {
  return {
    standard: pad.mapping === "standard",   // trust axes/buttons ordering only if true
    axes: pad.axes.length,                  // 4 on a normal twin stick pad
    buttons: pad.buttons.length,            // 17 on standard mapping with a guide button
    analogTriggers: pad.buttons.slice(6, 8).every(b =&gt; typeof b.value === "number"),
    rumble: Boolean(pad.vibrationActuator)
  };
}
</code></pre>
<p>Use <code>pad.id</code> for display, and to let the user confirm what they have plugged in. Don't use it to decide what your code does.</p>
<h2 id="heading-telling-drift-from-a-human-hand">Telling Drift from a Human Hand</h2>
<p>Once you can actually read the sticks, you hit the real problem: an off centre reading doesn't mean the hardware is broken. It usually means a person is holding the stick.</p>
<p>The obvious detector is a threshold and a timer. If an axis stays past some value for N milliseconds, call it drift. I shipped that. It was wrong, and it was wrong in the worst direction, because our own on screen instruction told users to rotate both sticks in full circles, and a slow circle holds an axis past a threshold for a long time. The tester told people their working controllers were broken.</p>
<p>What separates the two cases isn't how far the stick is from centre. It's two things together.</p>
<p><strong>Gate one, is it near rest.</strong> Real drift is a small persistent offset, typically well under half deflection. A hand on a stick is usually much further out. Require the mean magnitude over the sample window to be below about 0.6.</p>
<p><strong>Gate two, is it directionally coherent.</strong> This is the one that does the work. Drift comes from a worn or miscalibrated sensor, so it holds one direction with very little variation. A human hand wanders, even when trying to hold still. Compare the length of the mean vector to the mean of the individual magnitudes. If every sample points the same way, those two numbers are nearly equal and the ratio approaches 1. If the samples fan out, the mean vector is shorter than the mean magnitude and the ratio drops. Require above about 0.9.</p>
<pre><code class="language-js">// samples: array of { x, y } collected over a rolling window, one per frame
function looksLikeDrift(samples) {
  if (samples.length &lt; 30) return false;                 // not enough evidence yet

  const magnitude = s =&gt; Math.hypot(s.x, s.y);
  const meanMagnitude =
    samples.reduce((sum, s) =&gt; sum + magnitude(s), 0) / samples.length;

  if (meanMagnitude &lt; 0.02) return false;                // resting at centre, nothing wrong
  if (meanMagnitude &gt; 0.6) return false;                 // gate 1: too far out to be drift

  const meanX = samples.reduce((sum, s) =&gt; sum + s.x, 0) / samples.length;
  const meanY = samples.reduce((sum, s) =&gt; sum + s.y, 0) / samples.length;
  const coherence = Math.hypot(meanX, meanY) / meanMagnitude;

  return coherence &gt; 0.9;                                // gate 2: holds one direction
}
</code></pre>
<p>And the collector that feeds it:</p>
<pre><code class="language-js">const window_ = [];
const WINDOW = 120;   // about two seconds at 60fps

requestAnimationFrame(function loop() {
  const pad = navigator.getGamepads()[0];
  if (pad) {
    window_.push({ x: pad.axes[0], y: pad.axes[1] });
    if (window_.length &gt; WINDOW) window_.shift();
    if (looksLikeDrift(window_)) console.log("left stick looks like drift");
  }
  requestAnimationFrame(loop);
});
</code></pre>
<p>The measurement, because a claim like this is worth a number: replaying the same 64 seconds of recorded live controller input, the threshold and timer version raised a drift condition on <strong>2,144 frames</strong>. The two gate version raised it on <strong>zero</strong>. That recording contained no drifting hardware. Every one of those 2,144 frames was a person moving a stick, mostly following our own instructions.</p>
<p>Neither gate is magic, and it's worth saying where this one still fails. A hand held deliberately still and off centre in a single direction passes both gates, because that's genuinely hard to tell apart from a worn sensor by looking at the numbers alone. The fix isn't a third gate, it's context: run the check during a moment when you've asked the user to let go of the sticks, rather than against arbitrary input. Detection logic gets much easier when you control the conditions it runs in.</p>
<p>One design rule came out of that, and it generalises well beyond controllers: a gate may only suppress a report, never create one. Both gates can veto. Neither can raise the alarm on its own. If you find yourself adding a rule that turns a quiet signal into a loud one, you're building a false positive generator.</p>
<h2 id="heading-known-limits">Known Limits</h2>
<p>There are four things worth knowing before you ship.</p>
<p>Vibration isn't portable. Feature detect <code>pad.vibrationActuator</code> and treat rumble as a bonus, never as a requirement.</p>
<p>Non-standard mappings are real. When <code>pad.mapping</code> isn't <code>"standard"</code>, the axis and button ordering is whatever the browser and driver agreed on, and index 0 isn't guaranteed to be anything in particular. Handle that case or refuse it explicitly, but don't assume it away.</p>
<p>Bluetooth polling is less consistent than USB. Sample intervals wobble, so anything you compute from timing should be tolerant of jitter rather than assuming a steady 60Hz.</p>
<p>And browser support for newer controllers lags the hardware. A controller released last year may be recognised by one browser and not another on the same machine, which makes "my controller doesn't work" a browser question at least as often as a hardware one.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The Gamepad API is small and mostly pleasant to work with. The thing to carry away is that it's not a direct line to the hardware. The browser sits in between, protecting the user from the page, and the value it hands you isn't always the value the controller sent.</p>
<p>So poll instead of listening, re-read <code>getGamepads()</code> every frame, wait for each axis to prove itself before you trust it, branch on capability rather than on the <code>id</code> string, and require more than a threshold before you tell someone their hardware is broken.</p>
<p>And test with a controller you know is broken. A tester that has only ever been run against working hardware hasn't been tested at all.</p>
<p>I build <a href="https://joycheck.io/">JoyCheck</a>, a browser-based gamepad tester, which is where these measurements come from.</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[ High-Frequency Real-Time Data in React: From Ring Buffers to OffscreenCanvas ]]>
                </title>
                <description>
                    <![CDATA[ React is great at many things. But if you've ever tried pushing thousands of data points per second through it, you'll quickly learn that React isn't a firehose. It's more like a garden hose. Try forc ]]>
                </description>
                <link>https://www.freecodecamp.org/news/high-frequency-real-time-data-in-react-from-ring-buffers-to-offscreencanvas/</link>
                <guid isPermaLink="false">6a84d268fa401f6597cd200e</guid>
                
                    <category>
                        <![CDATA[ react js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ performance ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ canvas ]]>
                    </category>
                
                    <category>
                        <![CDATA[ realtime ]]>
                    </category>
                
                    <category>
                        <![CDATA[ workers ]]>
                    </category>
                
                    <category>
                        <![CDATA[ concurrency ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vineeth Pawar ]]>
                </dc:creator>
                <pubDate>Tue, 18 Aug 2026 21:45:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c3ffaae8-51d5-4add-9a9e-e49443e49746.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>React is great at many things. But if you've ever tried pushing thousands of data points per second through it, you'll quickly learn that React isn't a firehose. It's more like a garden hose.</p>
<p>Try forcing too much through it, and either the lawn floods (your DOM) or the pipe bursts (your app).</p>
<p>There's a second observation that pairs with the first. Your laptop has 8 to 16 CPU cores. Your React app uses 1 of them, almost always. The main thread handles JavaScript, the DOM, layout, and paint setup. The other cores sit idle while the main thread struggles to keep a 60fps frame budget.</p>
<p>Both problems have the same shape: you need to keep React out of the hot path, and you need to use more than one thread. The patterns that get you there also happen to be the patterns behind Figma's canvas engine, Bloomberg's trading dashboards, and every biosignal viewer you've seen.</p>
<p>In one project, I had to visualise 19 EEG (brainwave) channels, each sending about 1,000 data points per second. That's almost 19,000 updates per second. If you feed all of that directly into React, the UI doesn't just slow down. It faints dramatically.</p>
<p>This article is the end-to-end architecture I landed on: the ring buffers, workers, shared memory, off-main rendering, and specific patterns that hold up under sustained multi-hour load.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-whos-already-doing-this">Who's Already Doing This?</a></p>
</li>
<li><p><a href="#heading-the-1khz-math">The 1kHz Math</a></p>
</li>
<li><p><a href="#heading-where-it-usually-goes-wrong">Where it Usually Goes Wrong</a></p>
</li>
<li><p><a href="#heading-the-mental-model-air-traffic-control-plus-a-kitchen-brigade">The Mental Model: Air Traffic Control Plus a Kitchen Brigade</a></p>
</li>
<li><p><a href="#heading-step-1-stop-putting-samples-in-react-state">Step 1: Stop Putting Samples in React State</a></p>
</li>
<li><p><a href="#heading-step-2-separate-shape-from-values">Step 2: Separate Shape from Values</a></p>
</li>
<li><p><a href="#heading-step-3-move-heavy-work-off-the-main-thread">Step 3: Move Heavy Work Off the Main Thread</a></p>
</li>
<li><p><a href="#heading-step-4-render-off-main-with-offscreencanvas">Step 4: Render Off Main with OffscreenCanvas</a></p>
</li>
<li><p><a href="#heading-step-5-decimate-before-you-draw">Step 5: Decimate Before You Draw</a></p>
</li>
<li><p><a href="#heading-step-6-wrap-an-external-renderer">Step 6: Wrap an External Renderer</a></p>
</li>
<li><p><a href="#heading-step-7-when-canvas-isnt-enough-reach-for-webgl">Step 7: When Canvas isn't Enough, Reach for WebGL</a></p>
</li>
<li><p><a href="#heading-step-8-keep-memory-flat">Step 8: Keep Memory Flat</a></p>
</li>
<li><p><a href="#heading-step-9-scheduling-strategies">Step 9: Scheduling Strategies</a></p>
</li>
<li><p><a href="#heading-step-10-measure-sustained-performance">Step 10: Measure Sustained Performance</a></p>
</li>
<li><p><a href="#heading-case-study-19-eeg-channels">Case Study: 19 EEG Channels</a></p>
</li>
<li><p><a href="#heading-benchmarks-single-thread-vs-multi-thread">Benchmarks: Single Thread vs Multi-Thread</a></p>
</li>
<li><p><a href="#heading-the-coopcoep-catch">The COOP/COEP Catch</a></p>
</li>
<li><p><a href="#heading-production-tradeoffs">Production Tradeoffs</a></p>
</li>
<li><p><a href="#heading-should-you-build-like-this">Should You Build Like 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 get the most out of this article, you'll want:</p>
<ul>
<li><p><strong>Working knowledge of React 18 or 19.</strong> You should be comfortable with <code>useState</code>, <code>useEffect</code>, <code>useRef</code>, and the difference between mounting and re-rendering.</p>
</li>
<li><p><strong>TypeScript basics.</strong> Most examples are in TypeScript. You should be able to read type annotations without stopping.</p>
</li>
<li><p><strong>A rough sense of the browser main thread and event loop.</strong> You don't need to have written a Web Worker, but knowing what "blocking the main thread" means will make Step 3 easier.</p>
</li>
<li><p><strong>Familiarity with Canvas 2D or a chart library</strong> is a plus, not a requirement. If you've drawn anything on a canvas, you're ready.</p>
</li>
<li><p><strong>A laptop that can run modern Chrome or Edge.</strong> The examples rely on <code>SharedArrayBuffer</code>, <code>OffscreenCanvas</code>, and Atomics, which need a Chromium-based browser and cross-origin isolation (covered later in the article).</p>
</li>
</ul>
<p>You don't need prior experience with Web Workers, ring buffers, or WebGL. This article introduces each in the context of a real problem.</p>
<h2 id="heading-whos-already-doing-this">Who's Already Doing This?</h2>
<p>The patterns in this article aren't experimental. They're the architecture behind production apps that ingest and render high-frequency data:</p>
<ul>
<li><p><strong>Trading and finance dashboards</strong> (Bloomberg, Hyperliquid, dYdX, every serious market viewer) push thousands of price ticks per second through canvas-rendered grids.</p>
</li>
<li><p><strong>Figma</strong> runs its entire canvas engine in WebAssembly inside a worker. The main thread renders React for the chrome only.</p>
</li>
<li><p><strong>Google Docs and Microsoft Loop</strong> run their document models in workers, with the DOM as the projection.</p>
</li>
<li><p><strong>Charting libraries</strong> like LightningChart, uPlot, Plotly, and ECharts draw on Canvas or WebGL and treat React as a wrapper.</p>
</li>
<li><p><strong>Biosignal, ECG, EEG, and motion-capture apps</strong> routinely process samples at 1kHz or higher and stream them to live plots.</p>
</li>
<li><p><strong>Observability and APM tools</strong> (Datadog live tail, Grafana real-time panels) decouple ingestion from render to keep tabs responsive.</p>
</li>
<li><p><strong>Audio editors and visualisers</strong>, plus anything using the Web Audio API with a waveform display.</p>
</li>
<li><p><strong>transformers.js and ONNX Runtime Web</strong> place ML inference in workers by default.</p>
</li>
</ul>
<p>Different domains, same trick: React owns what changes rarely, something else owns what changes at refresh rate, and heavy work happens on threads that aren't the main one.</p>
<h2 id="heading-the-1khz-math">The 1kHz Math</h2>
<p>Some numbers to make the problem concrete.</p>
<ul>
<li><p>A sample arrives every 1ms.</p>
</li>
<li><p>A 60Hz display refreshes every 16.67ms.</p>
</li>
<li><p>So in one frame, you'll receive roughly <strong>16 to 17 samples per stream</strong>.</p>
</li>
<li><p>With 19 active streams (the EEG case), that's <strong>300 to 320 samples per frame</strong>.</p>
</li>
</ul>
<p>If you <code>setState</code> on each sample, React tries to do around 19,000 renders per second. It can't, so it skips frames. The UI stutters and your laptop fans take off.</p>
<p>If you <code>setState</code> once per frame with the batch of ~320 samples, React does 60 renders per second, which is easy.</p>
<p>That single reframe is the entire trick, and it will echo through every step below.</p>
<h2 id="heading-where-it-usually-goes-wrong">Where it Usually Goes Wrong</h2>
<p>Here's the version of the code I see in most real-time React apps the first time they try this. It looks reasonable. It's also the source of every bit of jank the team will spend the next two weeks tracking down.</p>
<pre><code class="language-tsx">import { useEffect, useState } from "react";

export default function NaiveChart({ socket }) {
  const [data, setData] = useState&lt;number[]&gt;([]);

  useEffect(() =&gt; {
    socket.on("newPoint", (point: number) =&gt; {
      setData((prev) =&gt; [...prev, point]); // re-renders every time
    });
  }, [socket]);

  return &lt;div&gt;{data.length} points&lt;/div&gt;;
}
</code></pre>
<p>Three problems baked into seven lines:</p>
<ol>
<li><p><strong>Every incoming sample triggers a re-render:</strong> At 1kHz that's 1,000 renders per second. React was never going to be happy about that.</p>
</li>
<li><p><code>[...prev, point]</code> <strong>allocates a new array on every push:</strong> At 1kHz that's a new array per millisecond, all of which the garbage collector has to clean up. The heap climbs, GC pauses lengthen, the fan kicks in.</p>
</li>
<li><p><strong>There's no upper bound on the array:</strong> Run this for an hour and you have 3.6 million numbers in memory, all of which React has to consider on every render.</p>
</li>
</ol>
<p>The fix isn't one trick. It's a stack of small ones, each addressing one of those failure modes and, eventually, the deeper problem of the main thread being the only thread.</p>
<h2 id="heading-the-mental-model-air-traffic-control-plus-a-kitchen-brigade">The Mental Model: Air Traffic Control Plus a Kitchen Brigade</h2>
<p>Before the code, two metaphors that will keep the pieces straight.</p>
<p><strong>First, air traffic control:</strong> Three roles, one airport.</p>
<ul>
<li><p>The <strong>control tower</strong> (your store) sees every plane (every sample) and tracks where it is.</p>
</li>
<li><p>The <strong>ground crew</strong> (React) sets up the runways and gates: the layout that planes use.</p>
</li>
<li><p>The <strong>pilots</strong> (the draw loop) actually fly. They look at the tower for clearance and act every few seconds.</p>
</li>
</ul>
<p>The control tower doesn't pull a gate from the ground crew every time a plane moves. It just holds the data. The ground crew rearranges gates when the schedule changes, which is rare. The pilots act constantly, at their own rate, off the tower's data.</p>
<p><strong>Second, a kitchen brigade:</strong> A restaurant kitchen at peak hour. One head chef can't make every dish. The classical brigade has stations: sauce, fish, pastry, garde manger, plating, and service. Each station owns a slice of the meal. The expediter coordinates timing.</p>
<ul>
<li><p>The <strong>expediter</strong> is your main thread.</p>
</li>
<li><p>The <strong>stations</strong> are your workers.</p>
</li>
<li><p>The <strong>plating window</strong> is your shared memory.</p>
</li>
<li><p>The <strong>dishes going to tables</strong> are your rendered frames.</p>
</li>
</ul>
<p>A single chef trying to do everything serially is your main-thread-only frontend. A brigade is your worker-based one. The brigade is faster because cuts, sauces, and sears happen in parallel, not because any one cook is faster than the soloist. The expediter doesn't cook, they orchestrate.</p>
<p>Everything below is a specific application of these two ideas.</p>
<h2 id="heading-step-1-stop-putting-samples-in-react-state">Step 1: Stop Putting Samples in React State</h2>
<p>The single biggest mistake in real-time React apps is treating every sample as state, which it isn't. State is what determines <em>which components exist and how they're arranged</em>. A live plot is one component. The 60,000 samples scrolling across it aren't 60,000 pieces of state. They're one buffer.</p>
<p>You want a store that lives outside React. A ring buffer over a typed array gives you constant-time inserts and a bounded heap:</p>
<pre><code class="language-ts">type Listener = () =&gt; void;

export function createSampleStore(capacity: number) {
  const buf = new Float32Array(capacity);
  let head = 0;
  let size = 0;
  const listeners = new Set&lt;Listener&gt;();

  return {
    push(sample: number) {
      buf[head] = sample;
      head = (head + 1) % capacity;
      if (size &lt; capacity) size++;
    },
    pushBatch(samples: Float32Array) {
      for (let i = 0; i &lt; samples.length; i++) {
        buf[head] = samples[i];
        head = (head + 1) % capacity;
        if (size &lt; capacity) size++;
      }
    },
    read(): Float32Array {
      if (size &lt; capacity) return buf.subarray(0, size);
      const out = new Float32Array(capacity);
      out.set(buf.subarray(head));
      out.set(buf.subarray(0, head), capacity - head);
      return out;
    },
    subscribe(l: Listener) {
      listeners.add(l);
      return () =&gt; listeners.delete(l);
    },
  };
}
</code></pre>
<p>Notice what's not here: no <code>useState</code>, no setter, no React anything. It's just plain JavaScript. The store can accept a million pushes per second and React won't care, because React isn't subscribed.</p>
<p>Here are the ring buffer mechanics, visualised:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779361162753/2374fc6e-429c-4a8a-8b31-30fb51c6052c.png" alt="Ring buffer with eight indexed slots, a head pointer advancing each push and wrapping around when it reaches the end, providing constant-time inserts and a bounded heap" style="display: block;" width="1600" height="463" loading="lazy">

<p>We have head advances on every push and wraps at capacity. Reads pull a window of the last N samples in chronological order. The result is constant time and a bounded heap.</p>
<p>A simpler stop-gap, when you don't want a typed buffer yet, is to put the rolling window in a ref instead of state:</p>
<pre><code class="language-tsx">import { useEffect, useRef } from "react";

export default function RefChart({ socket }) {
  const bufferRef = useRef&lt;number[]&gt;([]);

  useEffect(() =&gt; {
    socket.on("newPoint", (point: number) =&gt; {
      bufferRef.current.push(point);
      if (bufferRef.current.length &gt; 1000) {
        bufferRef.current.shift();
      }
    });
  }, [socket]);

  return &lt;div&gt;Streaming {bufferRef.current.length} points&lt;/div&gt;;
}
</code></pre>
<p>This is the "10x faster than <code>NaiveChart</code>, still not great" version. The render doesn't trigger on every push, but you also won't see updates unless something else re-renders. For real plotting, pair the ref with a <code>requestAnimationFrame</code> draw loop (see Step 2).</p>
<h2 id="heading-step-2-separate-shape-from-values">Step 2: Separate Shape from Values</h2>
<p>React renders when the <em>shape</em> of the UI changes. New plot? Re-render. Removed plot? Re-render. Switched from line to bar? Re-render. None of those happen at 1000Hz. They happen a few times a minute, when the user clicks something.</p>
<p>The <em>values</em> inside each plot change at the data rate. Those should never touch React.</p>
<p>Concretely:</p>
<pre><code class="language-tsx">function LivePlot({ store }: { store: SampleStore }) {
  const canvasRef = useRef&lt;HTMLCanvasElement&gt;(null);

  useEffect(() =&gt; {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d")!;

    let raf = 0;
    const draw = () =&gt; {
      const samples = store.read();
      drawSeries(ctx, samples);
      raf = requestAnimationFrame(draw);
    };
    raf = requestAnimationFrame(draw);
    return () =&gt; cancelAnimationFrame(raf);
  }, [store]);

  return &lt;canvas ref={canvasRef} width={1200} height={300} /&gt;;
}
</code></pre>
<p>React renders this component once. The <code>useEffect</code> runs once. The <code>requestAnimationFrame</code> loop reads from the store on every frame.</p>
<p>The store can be pushed to at any rate the source can manage. The user sees a smooth 60fps line whether the source is sending 100 samples per second or 100,000. The instinct to wire <code>samples</code> into <code>useState</code> is wrong, so resist it.</p>
<h3 id="heading-selective-subscriptions-when-you-do-need-react-in-the-loop">Selective Subscriptions When You Do Need React in the Loop</h3>
<p>Sometimes a component genuinely depends on the data (summary stats, axis labels, or a live value badge). For that, use a store with selective subscriptions so only the components that care re-render. Zustand makes this trivial:</p>
<pre><code class="language-ts">import { create } from "zustand";

const useDataStore = create&lt;{ latest: number | null; setLatest: (v: number) =&gt; void }&gt;((set) =&gt; ({
  latest: null,
  setLatest: (v) =&gt; set({ latest: v }),
}));

function LatestBadge() {
  // Only re-renders when latest changes, not when other store fields do.
  const latest = useDataStore((state) =&gt; state.latest);
  return &lt;div&gt;Latest: {latest?.toFixed(2)}&lt;/div&gt;;
}
</code></pre>
<p>Pair this with RAF coalescing on the writes (call <code>setLatest</code> once per frame, not once per sample) and you get a React component that updates smoothly at 60fps no matter what the data rate is.</p>
<p><code>useSyncExternalStore</code> is the native equivalent and works against any pub-sub store, including the ring buffer above. Use whichever feels lighter for your team.</p>
<h2 id="heading-step-3-move-heavy-work-off-the-main-thread">Step 3: Move Heavy Work Off the Main Thread</h2>
<p>The store and imperative draw loop handle React's contribution to the bottleneck. The main thread itself is still doing all the ingest, parsing, and math. Everything past ~50,000 samples per second per stream needs more.</p>
<p>The browser gives you four escape hatches: Web Workers, transferable objects, <code>SharedArrayBuffer</code> + Atomics, and <code>OffscreenCanvas</code>. Each solves a specific problem.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369113008/d1d608c9-8078-4339-91e1-c9ee221bf014.png" alt="Browser process model with the renderer process containing the main thread, dedicated workers, shared workers, a service worker, and a compositor thread; the GPU process containing the GPU thread for WebGL and WebGPU; and the network process containing the network thread for fetch and WebSocket, with communication between main and worker types happening via postMessage" style="display: block;" width="1600" height="1006" loading="lazy">

<p>The main thread runs JavaScript, the DOM, layout, and paint setup. Workers are isolated JavaScript contexts with their own event loops. They can't touch the DOM, they can't share memory by default, and every cross-thread message is async and copied unless you transfer it.</p>
<p>Three properties matter as you design against this model:</p>
<ul>
<li><p><strong>Workers can't touch the DOM:</strong> That's the point. They run pure JavaScript. Perfect for data work, network parsing, math, codecs, ML inference.</p>
</li>
<li><p><strong>Communication is async:</strong> No shared variable to read in the middle of a function. Plan APIs around requests and events.</p>
</li>
<li><p><strong>Data copies, unless you transfer or share it:</strong> Structured clone is the default, transferable objects skip the copy, and <code>SharedArrayBuffer</code> skips it permanently.</p>
</li>
</ul>
<h3 id="heading-dedicated-workers-in-practice">Dedicated Workers in Practice</h3>
<p>Here's a minimal worker for ingest:</p>
<pre><code class="language-ts">// worker.ts
self.onmessage = (event) =&gt; {
  const { samples } = event.data;
  // Decode, filter, decimate. The main thread doesn't see any of this.
  const summary = computeStats(samples);
  postMessage(summary);
};
</code></pre>
<pre><code class="language-tsx">// main thread
import { useEffect, useRef } from "react";

export default function WorkerChart({ socket, store }) {
  const workerRef = useRef&lt;Worker&gt;();

  useEffect(() =&gt; {
    workerRef.current = new Worker(new URL("./worker.ts", import.meta.url), {
      type: "module",
    });

    workerRef.current.onmessage = (event) =&gt; {
      store.pushBatch(event.data); // pre-processed, cheap to ingest
    };

    socket.on("newPoint", (point: number) =&gt; {
      workerRef.current?.postMessage({ point });
    });

    return () =&gt; workerRef.current?.terminate();
  }, [socket, store]);

  return &lt;LivePlot store={store} /&gt;;
}
</code></pre>
<p>Three things to internalise about workers.</p>
<p>First, workers are real processes from the runtime's perspective. Each has its own heap, event loop, and <code>globalThis</code>. Starting one costs about 1 to 5ms. Don't spin them up inside hot paths.</p>
<p>Second, module workers are the modern default. <code>type: "module"</code> enables ES modules inside the worker, including <code>import</code>. The legacy <code>importScripts</code> is for classic workers, so avoid it for new code.</p>
<p>Finally, bundlers know about workers. Vite, Webpack, esbuild, and Rspack all detect the <code>new Worker(new URL("./x.ts", import.meta.url))</code> pattern and produce a separate chunk for the worker.</p>
<h3 id="heading-a-worker-pool-that-scales-with-cores">A Worker Pool That Scales with Cores</h3>
<p>For CPU-bound work (parsing binary frames, decoding audio, computing FFTs), a pool spreads jobs across all available cores:</p>
<pre><code class="language-ts">class WorkerPool {
  private workers: Worker[];
  private next = 0;
  private pending = new Map&lt;string, (result: unknown) =&gt; void&gt;();

  constructor(scriptUrl: URL, size = Math.max(1, navigator.hardwareConcurrency - 1)) {
    this.workers = Array.from({ length: size }, () =&gt; {
      const w = new Worker(scriptUrl, { type: "module" });
      w.onmessage = (event) =&gt; {
        const cb = this.pending.get(event.data.id);
        if (!cb) return;
        this.pending.delete(event.data.id);
        cb(event.data.result);
      };
      return w;
    });
  }

  async run&lt;T&gt;(kind: string, payload: unknown, transferables: Transferable[] = []): Promise&lt;T&gt; {
    const id = crypto.randomUUID();
    const result = new Promise&lt;T&gt;((resolve) =&gt; this.pending.set(id, resolve as (r: unknown) =&gt; void));
    const worker = this.workers[this.next];
    this.next = (this.next + 1) % this.workers.length;
    worker.postMessage({ id, kind, payload }, transferables);
    return result;
  }
}

export const pool = new WorkerPool(new URL("./decoder.worker.ts", import.meta.url));
</code></pre>
<p>Round-robin distribution with one worker per core minus one. Pending requests resolve when the matching response arrives. It's cheap, predictable, and scales linearly until you hit memory bandwidth limits.</p>
<h3 id="heading-transferable-objects-the-no-copy-path">Transferable Objects, the No-copy Path</h3>
<p><code>postMessage</code> clones the payload by default. Cloning a 10MB buffer takes milliseconds and doubles your memory use. The fix is to <strong>transfer</strong> the buffer instead.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369124743/0a53c521-618c-48ec-a5c5-935e1acdbf1a.png" alt="Side-by-side comparison of postMessage modes: default postMessage clones the buffer with a copy landing in both the main heap and the worker heap via structured clone, while Transferable transfers ownership so the same buffer exists once with ownership moving from main to worker" style="display: block;" width="1600" height="4546" loading="lazy">

<p>The semantics: when you transfer a buffer, the sender loses access. The receiver gains it, with no copy and an O(1) handoff.</p>
<pre><code class="language-ts">const buf = new ArrayBuffer(10 * 1024 * 1024); // 10 MB
new Uint8Array(buf).set(somePayload);

worker.postMessage({ buf }, [buf]); // second arg = list of transferables
// `buf` is now detached on this side. Accessing it throws.
</code></pre>
<p>The list of transferable types in 2026 includes <code>ArrayBuffer</code> (and any typed-array view backed by one), <code>MessagePort</code>, <code>ImageBitmap</code>, <code>OffscreenCanvas</code>, the stream types (<code>ReadableStream</code>, <code>WritableStream</code>, <code>TransformStream</code>), <code>RTCDataChannel</code>, <code>VideoFrame</code>, <code>AudioData</code>, and the WebTransport streams. The most useful for React plus real-time work are <code>ArrayBuffer</code>, <code>OffscreenCanvas</code>, and <code>MessagePort</code>.</p>
<p>A common pitfall is that forgetting to transfer creates silent slowness. The app works, but it copies every message. Profile worker <code>postMessage</code> calls. If they're showing milliseconds for "small" payloads, you're cloning when you should be transferring.</p>
<pre><code class="language-ts">// Bad: copies every frame.
worker.postMessage({ samples: float32Array });

// Good: transfers the underlying buffer.
worker.postMessage({ samples: float32Array }, [float32Array.buffer]);
</code></pre>
<p>The buffer is detached after transfer, so the sender needs to re-allocate (or pull from a pool of pre-allocated buffers) if it wants to keep producing.</p>
<h3 id="heading-sharedarraybuffer-and-atomics">SharedArrayBuffer and Atomics</h3>
<p>Transfer hands a buffer off. <strong>Sharing</strong> lets both threads see the same memory simultaneously.</p>
<pre><code class="language-ts">const sab = new SharedArrayBuffer(1024 * 1024); // 1 MB shared
worker.postMessage({ sab });

// Both main and worker now hold references to the same memory.
const viewMain = new Int32Array(sab);
// Inside worker:
// const viewWorker = new Int32Array(event.data.sab);
</code></pre>
<p>Three properties matter.</p>
<p><code>SharedArrayBuffer</code> needs cross-origin isolation. Your page must be served with <code>Cross-Origin-Opener-Policy: same-origin</code> and <code>Cross-Origin-Embedder-Policy: require-corp</code>. Without these headers, <code>SharedArrayBuffer</code> is undefined in the browser. Covered in more detail later in the article.</p>
<p>You also need <code>Atomics</code> for synchronisation. Multiple threads writing to the same memory without coordination produces undefined results. <code>Atomics</code> gives you compare-and-swap, load, store, add, sub, wait, and notify operations on typed array views.</p>
<pre><code class="language-ts">const sab = new SharedArrayBuffer(8);
const view = new Int32Array(sab);

// Main thread: wake any worker that's waiting on slot 0.
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1);

// Worker: block until slot 0 changes from 0.
const result = Atomics.wait(view, 0, 0); // "ok", "not-equal", or "timed-out"
</code></pre>
<p>And lock-free ring buffers are the killer app. A producer-consumer queue between two threads with no locking, no <code>postMessage</code> round-trip, and no GC pressure. The data sits in the shared buffer. Atomics coordinate read/write positions.</p>
<p>Here's a minimal SPSC (single-producer, single-consumer) ring buffer:</p>
<pre><code class="language-ts">type SharedRing = {
  data: Float32Array;          // payload
  control: Int32Array;         // [head, tail]
};

function createSharedRing(capacity: number): SharedRing {
  const sab = new SharedArrayBuffer(capacity * 4 + 16);
  const control = new Int32Array(sab, 0, 4);   // [head, tail, ...]
  const data = new Float32Array(sab, 16, capacity);
  return { data, control };
}

function push(ring: SharedRing, value: number): boolean {
  const head = Atomics.load(ring.control, 0);
  const tail = Atomics.load(ring.control, 1);
  const next = (head + 1) % ring.data.length;
  if (next === tail) return false; // full
  ring.data[head] = value;
  Atomics.store(ring.control, 0, next);
  return true;
}

function pop(ring: SharedRing): number | null {
  const tail = Atomics.load(ring.control, 1);
  const head = Atomics.load(ring.control, 0);
  if (tail === head) return null; // empty
  const value = ring.data[tail];
  Atomics.store(ring.control, 1, (tail + 1) % ring.data.length);
  return value;
}
</code></pre>
<p>The producer writes from one thread. The consumer reads from another, neither blocks, and there's no <code>postMessage</code> between them. At signal rates, this is the only architecture that scales.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369132294/fcefe558-040e-41d3-b759-a8c10c5d5502.png" alt="Single-producer single-consumer ring buffer over SharedArrayBuffer: the producer (ingest worker) writes at the head pointer and increments it, the consumer (render thread) reads from the tail pointer and increments it, both pointers stored in the shared buffer and updated with atomic operations" style="display: block;" width="1600" height="308" loading="lazy">

<p>For multi-producer or multi-consumer queues, you need compare-and-swap loops (<code>Atomics.compareExchange</code>). They get fiddly fast. Most production setups use SPSC where they can and fall back to message-passing where they can't.</p>
<h3 id="heading-in-electron-ingest-in-the-main-process">In Electron, Ingest in the Main Process</h3>
<p>The same idea applies one level up. With Electron and a native SDK, you can ingest samples in the <strong>main process</strong>, buffer there, and forward batches across IPC at the renderer's refresh cadence. One IPC message per sample at 1kHz will saturate IPC. One IPC message per frame with a batch of 16 samples is trivial.</p>
<pre><code class="language-ts">// electron main: buffer in Node, flush at 60Hz
let pending: number[] = [];

device.on("sample", (value) =&gt; pending.push(value));

setInterval(() =&gt; {
  if (pending.length === 0) return;
  const batch = new Float32Array(pending);
  pending = [];
  // Transferable to avoid the structured-clone copy.
  mainWindow.webContents.send("device:samples", batch.buffer, [batch.buffer]);
}, 1000 / 60);
</code></pre>
<pre><code class="language-ts">// preload: expose a thin subscription API to the renderer
contextBridge.exposeInMainWorld("device", {
  onSamples: (cb: (samples: Float32Array) =&gt; void) =&gt; {
    const handler = (_: unknown, buf: ArrayBuffer) =&gt; cb(new Float32Array(buf));
    ipcRenderer.on("device:samples", handler);
    return () =&gt; ipcRenderer.removeListener("device:samples", handler);
  },
});
</code></pre>
<pre><code class="language-tsx">// renderer: feed the store from the bridge
useEffect(() =&gt; {
  return window.device.onSamples((batch) =&gt; store.pushBatch(batch));
}, []);
</code></pre>
<p>The renderer thread of the renderer touches only one batch per frame, no matter how fast the device is. The data work happens upstream.</p>
<h2 id="heading-step-4-render-off-main-with-offscreencanvas">Step 4: Render Off Main with <code>OffscreenCanvas</code></h2>
<p>The DOM is single-threaded. The Canvas API used to be too. <code>OffscreenCanvas</code> breaks that: a canvas you can transfer to a worker, where it draws independently of main.</p>
<pre><code class="language-ts">// Main thread
const canvas = canvasRef.current!;
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
</code></pre>
<pre><code class="language-ts">// Worker
let ctx: OffscreenCanvasRenderingContext2D | null = null;

self.onmessage = (event) =&gt; {
  if (event.data.canvas) {
    ctx = event.data.canvas.getContext("2d");
    return;
  }
  if (event.data.samples &amp;&amp; ctx) {
    drawSeries(ctx, event.data.samples);
  }
};
</code></pre>
<p>The main thread is now free to handle clicks, hover, and other interaction without competing with the draw loop. The worker draws at its own rate, against whatever data it has.</p>
<p>Combined with <code>SharedArrayBuffer</code>, you get the cleanest real-time rendering pipeline available in the browser:</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779369135331/1a205236-981d-4df7-8b21-698d15b84a63.png" alt="Rendering pipeline off the main thread: a data source feeds an ingest worker which writes into a SharedArrayBuffer, which a render worker owning an OffscreenCanvas reads to issue GPU commands that update the DOM, while the main thread reads a summary from the SharedArrayBuffer for React UI" style="display: block;" width="1600" height="260" loading="lazy">

<p>The ingest worker writes samples into the shared buffer. The render worker reads them and draws. Main only reads summaries (FPS, latest value, channel labels) and renders chrome. No data ever crosses through main's event loop.</p>
<p>For 19-channel signal at 1kHz, this is the only architecture that keeps a guaranteed 60fps on a laptop, with headroom.</p>
<h2 id="heading-step-5-decimate-before-you-draw">Step 5: Decimate Before You Draw</h2>
<p>A 1200-pixel-wide canvas can show, at best, 1200 distinct X positions. If you have 60,000 samples in your window and you draw all of them, you do 50 times more work than the user can see.</p>
<p>Pick the right point per pixel column. The "min-max" pattern works well for signals: for each pixel column, find the minimum and maximum value in that range and draw a vertical line between them. This is visually identical to drawing every point, but it's much cheaper.</p>
<pre><code class="language-ts">function drawDecimated(
  ctx: CanvasRenderingContext2D,
  samples: Float32Array,
  width: number,
) {
  const samplesPerPixel = samples.length / width;
  ctx.beginPath();
  for (let x = 0; x &lt; width; x++) {
    const start = Math.floor(x * samplesPerPixel);
    const end = Math.floor((x + 1) * samplesPerPixel);
    let min = Infinity;
    let max = -Infinity;
    for (let i = start; i &lt; end; i++) {
      const v = samples[i];
      if (v &lt; min) min = v;
      if (v &gt; max) max = v;
    }
    ctx.moveTo(x, scale(min));
    ctx.lineTo(x, scale(max));
  }
  ctx.stroke();
}
</code></pre>
<p><a href="https://en.wikipedia.org/wiki/Downsampling_(signal_processing)">Decimation</a> is the single biggest CPU win in real-time visualisation, and almost nobody does it.</p>
<p>A more visually faithful variant is <strong>LTTB (Largest Triangle Three Buckets)</strong>, an algorithm that picks one representative sample per bucket while preserving the visual shape better than min-max. It's worth the read if you're plotting non-signal data like stock charts where peaks and dips matter individually. Most good chart libraries (uPlot, Plotly, ECharts) include decimation out of the box.</p>
<h2 id="heading-step-6-wrap-an-external-renderer">Step 6: Wrap an External Renderer</h2>
<p>You don't always need to write the draw loop yourself. React is great at lifecycle management. Imperative chart libraries are great at raw performance. The right move is often to let React mount and unmount the chart while the library handles the fast inner loop.</p>
<pre><code class="language-tsx">import Uplot from "uplot";
import "uplot/dist/uPlot.min.css";
import { useEffect, useRef } from "react";

export default function UPlotChart({ data }: { data: AlignedData }) {
  const ref = useRef&lt;HTMLDivElement&gt;(null);
  const plotRef = useRef&lt;Uplot | null&gt;(null);

  useEffect(() =&gt; {
    const opts = {
      title: "Realtime Chart",
      width: 600,
      height: 300,
      series: [{}, { label: "Signal" }],
    };
    plotRef.current = new Uplot(opts, data, ref.current!);
    return () =&gt; plotRef.current?.destroy();
  }, []);

  useEffect(() =&gt; {
    plotRef.current?.setData(data); // imperative update, no React render
  }, [data]);

  return &lt;div ref={ref} /&gt;;
}
</code></pre>
<p>uPlot, TimeChart, ECharts, Plotly, LightningChart, and the others all follow this shape: instantiate inside <code>useEffect</code>, call <code>setData</code> imperatively, and destroy on unmount. React orchestrates and the library renders.</p>
<p>The general pattern, for any imperative renderer, looks like this:</p>
<pre><code class="language-tsx">function ChartWrapper({ config, data }) {
  const ref = useRef&lt;HTMLDivElement&gt;(null);

  useEffect(() =&gt; {
    const chart = new SomeFastChartLib(ref.current, config);
    chart.setData(data);
    return () =&gt; chart.destroy();
  }, [config, data]);

  return &lt;div ref={ref} /&gt;;
}
</code></pre>
<p>This is the highest-ROI move if you're not in a domain where you need pixel-level control over the drawing. Reach for a library first, and only write your own draw loop when no library fits.</p>
<h2 id="heading-step-7-when-canvas-isnt-enough-reach-for-webgl">Step 7: When Canvas isn't Enough, Reach for WebGL</h2>
<p>Canvas 2D handles a few thousand line segments per frame comfortably. Past that, you start spending milliseconds in <code>stroke()</code> itself.</p>
<p>WebGL (or its higher-level wrappers like <code>regl</code>, <code>twgl</code>, <code>pixi.js</code>, <code>deck.gl</code>) moves the rendering to the GPU. You pay an upfront cost (writing shaders and managing buffers) for the ability to draw millions of points without breaking a sweat.</p>
<p>Here's a minimal WebGL "line strip with a single vertex shader" sketch:</p>
<pre><code class="language-ts">const gl = canvas.getContext("webgl2")!;

const program = gl.createProgram()!;
// ... compile vertex + fragment shaders, link, get attribute location ...

const samplesBuffer = gl.createBuffer();
function drawWebGL(samples: Float32Array) {
  gl.bindBuffer(gl.ARRAY_BUFFER, samplesBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, samples, gl.STREAM_DRAW);
  gl.useProgram(program);
  gl.drawArrays(gl.LINE_STRIP, 0, samples.length);
}
</code></pre>
<p>The pattern is the same as Canvas 2D: an imperative draw call inside the RAF loop. The difference is the GPU does the actual rasterising. Most real-time charting libraries that claim "millions of points" are doing exactly this under the hood.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1779361166770/22241c5c-446b-4026-88fb-33e3caad3831.png" alt="Decision tree comparing rendering options: Canvas 2D for up to a few thousand points, WebGL for tens of thousands or more, and WebGPU for 3D, custom shaders, or compute once stable" style="display: block;" width="1600" height="1351" loading="lazy">

<p>Default to Canvas 2D, and escalate to WebGL only when you can prove Canvas is the bottleneck on the Profiler.</p>
<h2 id="heading-step-8-keep-memory-flat">Step 8: Keep Memory Flat</h2>
<p>Real-time apps die slowly. They run smoothly for an hour and then the laptop fan kicks in. The cause is almost always memory.</p>
<p>There are three rules that keep the heap calm.</p>
<p>First, use typed arrays for numeric data. A <code>Float32Array</code> of 60,000 floats is 240KB. A plain JavaScript array of the same size is around 1.4MB and creates GC pressure on every push.</p>
<p>Second, bound everything. Use ring buffers, not growing arrays. Put caps on history, queues, and pending work. Anything that <em>can</em> grow unbounded eventually will.</p>
<p>Third, reuse buffers. Allocate once in setup, and reuse across frames. Allocating a fresh array per frame at 60fps is 60 allocations per second per plot. Multiply by plot count.</p>
<pre><code class="language-ts">// Bad: fresh array every frame.
function drawFrame() {
  const snapshot = store.read(); // returns a new Float32Array
  drawDecimated(ctx, snapshot, width);
}

// Better: reuse a draw buffer.
const drawBuf = new Float32Array(WINDOW_SIZE);

function drawFrame() {
  store.readInto(drawBuf); // writes into the existing buffer
  drawDecimated(ctx, drawBuf, width);
}
</code></pre>
<p>These rules aren't React-specific. They're the rules any real-time system follows. The reason they need stating is that React-shaped thinking ("derive a new array each render") is the opposite of what real-time wants.</p>
<h2 id="heading-step-9-scheduling-strategies">Step 9: Scheduling Strategies</h2>
<p>A worker pool with no scheduling is just a queue. Sometimes you want priority: a user-driven action should jump ahead of a background sweep.</p>
<h3 id="heading-priority-queues">Priority Queues</h3>
<p>A small priority scheduler:</p>
<pre><code class="language-ts">type Priority = "high" | "normal" | "low";

class PriorityPool {
  private queues: Record&lt;Priority, Job[]&gt; = { high: [], normal: [], low: [] };
  private idle: Worker[];

  enqueue(job: Job, priority: Priority = "normal") {
    if (this.idle.length &gt; 0) {
      const worker = this.idle.pop()!;
      this.dispatch(worker, job);
    } else {
      this.queues[priority].push(job);
    }
  }

  private next(): Job | null {
    for (const p of ["high", "normal", "low"] as const) {
      const j = this.queues[p].shift();
      if (j) return j;
    }
    return null;
  }

  private dispatch(worker: Worker, job: Job) {
    worker.postMessage(job.payload, job.transferables);
    worker.onmessage = (event) =&gt; {
      job.resolve(event.data);
      const next = this.next();
      if (next) this.dispatch(worker, next);
      else this.idle.push(worker);
    };
  }
}
</code></pre>
<p>Three buckets are usually enough: high (user-initiated), normal (steady-state work), low (background sweeps, prefetch, telemetry flushing).</p>
<h3 id="heading-chunkable-work">Chunkable Work</h3>
<p>A job that takes 2 seconds blocks a worker for 2 seconds. If you want to keep workers responsive to higher-priority jobs, the job has to be chunkable.</p>
<pre><code class="language-ts">// In the worker:
self.onmessage = async (event) =&gt; {
  const { id, kind, payload } = event.data;
  if (kind === "decode_large") {
    const total = payload.byteLength;
    for (let i = 0; i &lt; total; i += CHUNK) {
      const chunk = decodeChunk(payload, i, Math.min(i + CHUNK, total));
      self.postMessage({ id, kind: "progress", chunk, offset: i });
      // Yield so the worker can check its message queue.
      await new Promise((r) =&gt; setTimeout(r, 0));
    }
    self.postMessage({ id, kind: "done" });
  }
  if (kind === "cancel") {
    // ... abort the current job
  }
};
</code></pre>
<p>Yielding inside a worker isn't free, but it lets the worker process cancellation messages or higher-priority jobs interleaved with the big task. For long jobs (like a firmware flash, large file decode, or big render), this is essential.</p>
<h3 id="heading-fan-out-and-fan-in">Fan-out and Fan-in</h3>
<p>Split a big job into N pieces, dispatch each to a different worker, and gather the results.</p>
<pre><code class="language-ts">async function decodeFile(buffer: ArrayBuffer): Promise&lt;DecodedFrame[]&gt; {
  const chunks = splitBuffer(buffer, 8);
  const results = await Promise.all(
    chunks.map((chunk) =&gt; pool.run&lt;DecodedFrame[]&gt;("decode", chunk, [chunk])),
  );
  return results.flat();
}
</code></pre>
<p>This gives you linear speedup on parallelisable workloads, up to the worker count. It's critical for batch operations like decoding a recording, summarising a long document, or computing embeddings for a folder.</p>
<h2 id="heading-step-10-measure-sustained-performance">Step 10: Measure Sustained Performance</h2>
<p>A spike on the Profiler is one thing. A slow heap creep over an hour is another. For real-time apps, you measure two things.</p>
<p>First, frame consistency. Are you holding 60fps consistently, or dropping occasional frames? A simple FPS meter:</p>
<pre><code class="language-ts">let lastTime = performance.now();
let frames = 0;

function rafLoop(now: number) {
  frames++;
  if (now - lastTime &gt;= 1000) {
    const fps = (frames * 1000) / (now - lastTime);
    frames = 0;
    lastTime = now;
    console.log(`fps: ${fps.toFixed(1)}`);
  }
  requestAnimationFrame(rafLoop);
}
requestAnimationFrame(rafLoop);
</code></pre>
<p>A more honest measure is the worst frame in a window. Average frame time hides the jank.</p>
<p>Second, heap stability. Open DevTools Memory tab, take a heap snapshot, run the app for ten minutes, take another snapshot, and then compare. The diff should be flat. If it's growing, you have a leak: retained listeners, growing arrays, or closures holding onto large objects.</p>
<p>For real apps, wire heap usage and FPS into your analytics so you spot regressions before users complain.</p>
<h2 id="heading-case-study-19-eeg-channels">Case Study: 19 EEG Channels</h2>
<p>Back to the project that started this article: we have nineteen channels, each at 1kHz, all rendered simultaneously, and all needing to stay smooth across multi-hour recording sessions.</p>
<p>The first build used <strong>LightningChart</strong>. It's powerful, capable, and beautiful. But it's also heavy. Memory usage climbed noticeably, the chart's own internals were doing a lot of work for our use case (which was 19 simple line plots, not full multi-axis financial charts), and the licensing was a friction point.</p>
<p>We switched to <strong>uPlot</strong>. It's tiny, fast, and written specifically for time-series. Memory usage dropped, render time per frame went from "occasionally over budget" to "always under," and my machine stopped sounding like it was about to take off. The chart library change alone bought us most of the headroom we needed.</p>
<p>The architecture around the chart did the rest. The pipeline runs on four threads:</p>
<ul>
<li><p><strong>One ingest worker</strong> reads from the device SDK over IPC (Electron main process to renderer).</p>
</li>
<li><p><strong>One</strong> <code>SharedArrayBuffer</code> holds the rolling window for all 19 channels.</p>
</li>
<li><p><strong>One render worker</strong> reads the SAB and draws to an <code>OffscreenCanvas</code>.</p>
</li>
<li><p><strong>Main thread</strong> renders chrome: channel labels, controls, and the FPS meter.</p>
</li>
</ul>
<p>The shared buffer layout is one big <code>Float32Array</code> indexed as <code>[channel * samplesPerChannel + sampleIndex]</code>. The ingest worker writes new samples and advances per-channel head pointers (stored in an <code>Int32Array</code> slice of the SAB). The render worker reads the latest window each frame.</p>
<pre><code class="language-ts">const CHANNELS = 19;
const WINDOW_SAMPLES = 60_000;

const sab = new SharedArrayBuffer(CHANNELS * WINDOW_SAMPLES * 4 + CHANNELS * 4);
const heads = new Int32Array(sab, 0, CHANNELS);
const samples = new Float32Array(sab, CHANNELS * 4, CHANNELS * WINDOW_SAMPLES);

function writeSample(channel: number, value: number) {
  const head = Atomics.load(heads, channel);
  samples[channel * WINDOW_SAMPLES + head] = value;
  Atomics.store(heads, channel, (head + 1) % WINDOW_SAMPLES);
}
</code></pre>
<p>The renderer reads the channel buffers, decimates per pixel column, and draws:</p>
<pre><code class="language-ts">// render.worker.ts
function drawFrame() {
  const ctx = offscreenCtx;
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  for (let ch = 0; ch &lt; CHANNELS; ch++) {
    const start = ch * WINDOW_SAMPLES;
    drawDecimatedRow(ctx, samples.subarray(start, start + WINDOW_SAMPLES), ch);
  }
  requestAnimationFrame(drawFrame);
}
</code></pre>
<p>On a 2024 M3 MacBook Pro, this holds 60fps with 19 channels at 1kHz, and 144fps if the display supports it. The main thread stays under 1% utilisation. Workers consume the spare cores. The user feels something that used to require a native app.</p>
<p>The lesson, in short: most of the work is choosing the right tool for the inner loop, and getting out of its way.</p>
<h2 id="heading-benchmarks-single-thread-vs-multi-thread">Benchmarks: Single Thread vs Multi-Thread</h2>
<p>Here are numbers from running comparable workloads on a 2024 M3 MacBook Pro. They're indicative, not promissory.</p>
<table>
<thead>
<tr>
<th>Workload</th>
<th>Main thread only</th>
<th>Worker pool</th>
<th>Workers + SAB</th>
<th>Workers + SAB + OffscreenCanvas</th>
</tr>
</thead>
<tbody><tr>
<td>Parse 100MB binary file</td>
<td>4.2s (UI frozen)</td>
<td>1.1s</td>
<td>1.0s</td>
<td>1.0s</td>
</tr>
<tr>
<td>Decode 1,000 frames</td>
<td>920ms</td>
<td>280ms</td>
<td>240ms</td>
<td>240ms</td>
</tr>
<tr>
<td>Render 1M-point chart</td>
<td>24fps</td>
<td>24fps</td>
<td>28fps</td>
<td>60fps</td>
</tr>
<tr>
<td>Telemetry: 4 streams x 1000Hz</td>
<td>22fps</td>
<td>38fps</td>
<td>55fps</td>
<td>60fps</td>
</tr>
<tr>
<td>EEG: 19 channels x 1kHz</td>
<td>12fps</td>
<td>25fps</td>
<td>48fps</td>
<td>60fps (144fps possible)</td>
</tr>
<tr>
<td>Main-thread JS time per frame</td>
<td>22ms</td>
<td>8ms</td>
<td>4ms</td>
<td>&lt; 1ms</td>
</tr>
<tr>
<td>Memory overhead</td>
<td>baseline</td>
<td>+50MB</td>
<td>+20MB</td>
<td>+20MB</td>
</tr>
<tr>
<td>Worker spin-up latency (first call)</td>
<td>0</td>
<td>2-5ms</td>
<td>2-5ms</td>
<td>5-10ms</td>
</tr>
</tbody></table>
<p>The pattern: workers alone help, and workers plus shared memory help more. Workers plus shared memory plus <code>OffscreenCanvas</code> is what gets you to "the main thread is doing nothing and the chart is still smooth."</p>
<p>For chart-heavy apps, the leap from "workers" to "workers plus <code>OffscreenCanvas</code>" is the biggest single architectural improvement available without leaving the browser.</p>
<h2 id="heading-the-coopcoep-catch">The COOP/COEP Catch</h2>
<p><code>SharedArrayBuffer</code> and high-resolution timers were tightened in 2020 after Spectre/Meltdown. To use them, your page must be served with two HTTP headers:</p>
<pre><code class="language-plaintext">Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
</code></pre>
<p>This opts your page into "cross-origin isolation." Within an isolated context, <code>SharedArrayBuffer</code> exists, <code>performance.now()</code> is high-resolution, and various other restricted APIs work.</p>
<p>Outside an isolated context, <code>SharedArrayBuffer</code> is undefined, <code>performance.now()</code> is throttled to about 1ms precision, and Atomics throw.</p>
<p>The cost is significant. <code>require-corp</code> means every cross-origin resource (images from a CDN, embedded YouTube videos, third-party fonts, analytics scripts) must explicitly opt in by setting <code>Cross-Origin-Resource-Policy: cross-origin</code> or <code>Cross-Origin-Embedder-Policy: credentialless</code>. Many third-party services don't, which breaks their embedding.</p>
<p>There are two practical options:</p>
<ul>
<li><p><strong>For an Electron app:</strong> the renderer process can be configured to use these headers easily. Most production Electron apps that want real-time visualisation enable them by default.</p>
</li>
<li><p><strong>For a browser app:</strong> weigh the embeds you'd lose against the performance you'd gain. If your app is the main attraction (Figma, Google Docs), opt in. If you depend on third-party widgets, the cost is real.</p>
</li>
</ul>
<p>For chart-heavy or signal-heavy apps that need SAB, the Electron path is usually cleaner.</p>
<h2 id="heading-production-tradeoffs">Production Tradeoffs</h2>
<p>Here are the five real costs of everything above.</p>
<ul>
<li><p><strong>Code complexity:</strong> A worker-driven app has 2 to 4 times the source files of a single-threaded one (main + workers + shared types). It's worth it for the right scale, but painful for a trivial app.</p>
</li>
<li><p><strong>Debugging:</strong> Stack traces split across threads. Chrome DevTools handles this well in 2026 (each worker has its own debugger panel), but it's still more work than a single-thread bug.</p>
</li>
<li><p><strong>Bundle size:</strong> Each worker is a separate chunk. Tree-shaking inside workers is sometimes worse than in main (less mature). Audit worker bundles separately.</p>
</li>
<li><p><strong>Startup latency:</strong> Spinning up workers at app start adds 50 to 200ms. Pre-warm them during the splash screen, or accept the first-frame delay.</p>
</li>
<li><p><strong>Browser API gaps:</strong> <code>localStorage</code>, <code>document</code>, and most DOM APIs aren't available in workers. Some libraries silently rely on them and break. Test in a worker context before bundling a library you haven't tried there.</p>
</li>
</ul>
<p>There are three trade-offs specific to the imperative rendering pattern:</p>
<ul>
<li><p><strong>Declarative animation of the data:</strong> The chart frame, labels, and controls all stay declarative. The data inside the chart becomes imperative.</p>
</li>
<li><p><strong>Easy snapshot testing of the rendered output:</strong> A canvas has no DOM you can query. Test the data path separately from the draw path. Snapshot the store output, not the pixels.</p>
</li>
<li><p><strong>React's component story for the inner loop:</strong> The draw loop is a closure. Composing draw loops is harder than composing components. Pick your component boundary carefully so each canvas does one thing.</p>
</li>
</ul>
<p>For most apps these costs aren't worth paying. For an app that has to render 1kHz data smoothly, they're the price of admission.</p>
<h2 id="heading-should-you-build-like-this">Should You Build Like This?</h2>
<p>If your data rate is below 30 updates per second per stream, none of this is needed. Naïve <code>setState</code> per batch will work. Profile first, optimise second.</p>
<p>This architecture earns its keep when:</p>
<ul>
<li><p>You have many streams or sensors</p>
</li>
<li><p>Ingestion is sustained, not bursty</p>
</li>
<li><p>The UX promise is smooth motion for hours, not seconds</p>
</li>
<li><p>You'd rather not rewrite the UI in a native language to get there</p>
</li>
</ul>
<p>The boring rule: start with <code>requestAnimationFrame</code> coalescing and external stores. Promote to workers when those aren't enough. Promote to shared memory when worker <code>postMessage</code> is the bottleneck. Promote to <code>OffscreenCanvas</code> when the render loop itself becomes the bottleneck. Each step is a real architectural investment. Take them in order.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>React can handle real-time visualisation if you use it the right way. Instead of pushing React to do everything, use it as the conductor. Let specialised libraries and workers handle the heavy lifting.</p>
<p>These are the three rules that hold up across every high-frequency React app I've built:</p>
<ol>
<li><p><strong>Workers do the work, while main does the UI.</strong> If main is doing math, you've put the math in the wrong place.</p>
</li>
<li><p><strong>Transfer if you can, share if you must.</strong> Both beat cloning. Sharing is more complex than transferring.</p>
</li>
<li><p><strong>Let React orchestrate. Let specialised tools render.</strong> The store owns the data, the draw loop owns the values, and React owns the shape.</p>
</li>
</ol>
<p>Get those right and your React app stops being a one-core system that flinches at high-frequency data. It becomes a real multi-core system that scales with the hardware, ingests without dropping, and renders without stuttering.</p>
<p>The cores are right there. Use them.</p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://github.com/leeoniya/uPlot">uPlot</a>: tiny, fast, focused on time-series.</p>
</li>
<li><p><a href="https://github.com/huww98/TimeChart">TimeChart</a>: high-performance real-time chart on WebGL.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers">Using Web Workers in React</a>: MDN reference.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer">SharedArrayBuffer on MDN</a>: the shared-memory primitive.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas">OffscreenCanvas on MDN</a>: rendering off the main thread.</p>
</li>
<li><p><a href="https://web.dev/articles/coop-coep">COOP and COEP explainer</a>: what cross-origin isolation buys you.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Manage Context Files in Your Codebase and Get Better Output From AI Coding Agents ]]>
                </title>
                <description>
                    <![CDATA[ You ask a coding agent for a new endpoint, and ninety seconds later you have a working endpoint. Then you read the diff, and you find that it pulled in a validation library that's not in your package. ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-manage-context-files-in-your-codebase-and-get-better-agent-output/</link>
                <guid isPermaLink="false">6a831663dcf9ac784c9eae7d</guid>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Kayode Adeniyi ]]>
                </dc:creator>
                <pubDate>Mon, 17 Aug 2026 14:10:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/84f1d4b5-5874-4325-965f-0a009e3b3290.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You ask a coding agent for a new endpoint, and ninety seconds later you have a working endpoint.</p>
<p>Then you read the diff, and you find that it pulled in a validation library that's not in your <code>package.json</code>, it wrote the test in Jest even though your team moved to the Node test runner last spring, and it reached into the database from inside the route handler because it had no way of knowing that every other handler in the codebase delegates to a service.</p>
<p>The code runs, the tests it wrote pass, but you still have to rewrite most of it.</p>
<p>None of that is a reasoning failure on the model's part. It produced a sensible solution to the problem as it understood it, but it understood the problem badly because nobody told it how this particular codebase works.</p>
<p>Your conventions live in your team's heads, in code review comments, and in decisions made eighteen months ago that nobody wrote down. The agent can't see any of that, so it falls back on the average of every repository it has ever been trained on, which is exactly what you got.</p>
<p>The fix isn't a longer prompt, since you would have to retype it every session and your teammates would each write a different version of it. The fix is a set of files that live in the repository, load automatically, and are maintained the same way you maintain code.</p>
<p>This tutorial shows you how to structure those files, how to keep one source of truth across the four or five formats the different tools expect, and, most importantly, how to stop them from quietly going out of date. After all, a context file that describes a codebase you deleted six months ago is worse than no context file at all.</p>
<p>Everything here is built on a companion repository you can clone and run: <a href="https://github.com/Adeniyikayodee/MCF">github.com/Adeniyikayodee/MCF</a>. It has no dependencies, so Node 20 or newer is all you need.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-what-you-need-before-you-start">What You Need Before You Start</a></p>
</li>
<li><p><a href="#heading-why-the-context-window-is-the-real-constraint">Why the Context Window is the Real Constraint</a></p>
</li>
<li><p><a href="#heading-the-three-layers">The Three Layers</a></p>
</li>
<li><p><a href="#heading-picking-a-format-without-maintaining-four-copies">Picking a Format Without Maintaining Four Copies</a></p>
</li>
<li><p><a href="#heading-writing-the-root-file">Writing the Root File</a></p>
</li>
<li><p><a href="#heading-scoping-rules-to-a-directory">Scoping Rules to a Directory</a></p>
</li>
<li><p><a href="#heading-pointing-instead-of-inlining">Pointing Instead of Inlining</a></p>
</li>
<li><p><a href="#heading-making-context-files-verifiable">Making Context Files Verifiable</a></p>
</li>
<li><p><a href="#heading-give-the-agent-something-to-verify-against">Give the Agent Something to Verify Against</a></p>
</li>
<li><p><a href="#heading-checking-whether-it-actually-worked">Checking Whether it Actually Worked</a></p>
</li>
<li><p><a href="#heading-keeping-the-files-healthy">Keeping the Files Healthy</a></p>
</li>
<li><p><a href="#heading-mistakes-worth-avoiding">Mistakes Worth Avoiding</a></p>
</li>
<li><p><a href="#heading-where-to-start">Where to Start</a></p>
</li>
</ul>
<h2 id="heading-what-you-need-before-you-start">What You Need Before You Start</h2>
<p>You should be comfortable with Git and a terminal, you should have Node 20 or newer installed, and you should have used at least one coding agent such as Claude Code, Cursor, GitHub Copilot, or Codex on a real project.</p>
<p>You don't need to know anything about how models work internally, since everything in this tutorial is about files on disk.</p>
<h2 id="heading-why-the-context-window-is-the-real-constraint">Why the Context Window is the Real Constraint</h2>
<p>Everything an agent knows while it works on your task lives in one buffer called the context window. That buffer holds the system prompt, your conversation, every file the agent opened, every command it ran, and every stack trace those commands printed.</p>
<p>But it's important to know that it's finite, and it fills up faster than most people expect. A single debugging session, for example, can burn tens of thousands of tokens before the agent has written a line of code.</p>
<p>The part that matters for this tutorial is what happens as that buffer fills. Anthropic's engineering team describes an effect they call <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents">context rot</a>, where a model's ability to retrieve a specific instruction degrades as the token count climbs. The model isn't ignoring you out of stubbornness, it's working with an attention budget that gets thinner as more material competes for it.</p>
<p>That single fact overturns the intuition most people bring to context files. Writing more feels safer, because you've covered more cases and left less to chance. But every line you add competes with every other line for a finite amount of attention.</p>
<p>The Claude Code documentation puts the consequence plainly, noting that a bloated instructions file causes the agent to ignore the rules inside it. Also, it notes that the symptom of an over-long file is the agent repeatedly breaking a rule you've clearly written down.</p>
<p>Here's roughly how a session budget gets spent on a real task:</p>
<pre><code class="language-text">system prompt and tool definitions        ~12,000 tokens
context files loaded at startup            ~4,800 tokens
three source files the agent opened        ~9,000 tokens
one test run with a stack trace            ~3,500 tokens
</code></pre>
<p>The 4,800 token context file in that list is competing with the stack trace the agent needs to read in order to fix the bug. A 600 token file that names the right paths would leave room for the agent to go and read the code itself, which it's very good at.</p>
<p>Context files are a budget allocation problem before they're a documentation problem, and almost every improvement in this tutorial comes from taking that seriously.</p>
<h2 id="heading-the-three-layers">The Three Layers</h2>
<p>The structure that works treats context as three distinct layers with different costs.</p>
<p>The <strong>always loaded layer</strong> is a single file at the root of your repository that the agent reads at the start of every session, whether the task is a typo fix or a migration. You pay for this file on every single request, so it holds only what applies to every task in the repository. It also stays small enough that you could read it aloud in under a minute.</p>
<p>The <strong>scoped layer</strong> is made up of nested files that load only when the agent works inside a particular directory. Rules about your API layer sit in <code>src/AGENTS.md</code>, so a task that only touches the frontend never pays for them.</p>
<p>The <strong>on demand layer</strong> is ordinary documentation that the root file points at by path rather than inlining. A path costs a handful of tokens while the document behind it might cost two thousand, so the agent spends that budget only when the task actually calls for it.</p>
<p>This mirrors how a new engineer works, since they don't memorise your architecture document on day one. They remember that it exists and go and read it when they need it.</p>
<p>The finished layout in the companion repository looks like this:</p>
<pre><code class="language-text">MCF/
├── AGENTS.md                          always loaded, budgeted
├── CLAUDE.md                          generated from AGENTS.md
├── .github/copilot-instructions.md    generated from AGENTS.md
├── .cursor/rules/testing.mdc          glob scoped, hand written
├── .claude/
│   ├── settings.json                  hook that runs the context linter
│   └── skills/add-endpoint/SKILL.md   workflow, loaded on demand
├── docs/
│   ├── architecture.md
│   ├── testing.md
│   └── decisions/0001-in-memory-store.md
├── scripts/
│   ├── context-lint.mjs
│   └── sync-context.mjs
├── src/
│   ├── AGENTS.md                      scoped to the source tree
│   ├── api/tasks.js
│   ├── services/tasks.js
│   ├── lib/validate.js
│   ├── router.js
│   └── server.js
└── tests/
</code></pre>
<h2 id="heading-picking-a-format-without-maintaining-four-copies">Picking a Format Without Maintaining Four Copies</h2>
<p>Every vendor picked a different filename for the same idea, which is annoying but manageable once you decide which one is the source of truth.</p>
<p><code>AGENTS.md</code> is the closest thing to a shared convention. It's plain Markdown with no required schema, its governance sits with the Agentic AI Foundation under the Linux Foundation, and it's read natively by Claude Code, Codex, Cursor, Copilot, Gemini CLI, Aider, Windsurf, Zed, and a long list of others.</p>
<p>Nested files are part of the spec, the file closest to the code being edited takes precedence, and anything you type directly into the chat overrides all of it.</p>
<p>The tool-specific formats still exist alongside it. Claude Code reads <code>CLAUDE.md</code>, walks up the directory tree concatenating every one it finds, and resolves <code>@path/to/file</code> imports. Cursor uses <code>.mdc</code> files inside <code>.cursor/rules/</code> with YAML frontmatter that can scope a rule to a glob such as <code>tests/**/*.js</code>, which makes it the most expressive of the formats and also the least portable, since nothing outside Cursor reads it. GitHub Copilot, for its part, reads a single <code>.github/copilot-instructions.md</code> at the repository root.</p>
<p>The practical answer is to write <code>AGENTS.md</code> once, generate the rest from it, and hand write a separate file only when a tool offers something the shared format can't express. In practice, this means Cursor's glob scoping. You can do the generating with symlinks:</p>
<pre><code class="language-bash">ln -s AGENTS.md CLAUDE.md
</code></pre>
<p>Symlinks are the shortest path, though they cause trouble for contributors on Windows and for some CI checkout configurations, so the companion repository uses a small script instead. The script writes a banner into every file it generates, which stops a well-meaning teammate from editing the copy and losing their work on the next sync:</p>
<pre><code class="language-js">// scripts/sync-context.mjs
const banner = `&lt;!-- Generated from ${SOURCE} by \`npm run sync:context\`. Edit ${SOURCE} instead. --&gt;`;

export const targets = [
  // Claude Code resolves @path imports, so its file stays a pointer plus what is specific to it.
  { path: 'CLAUDE.md', render: () =&gt; `${banner}\n\n@${SOURCE}\n\n${CLAUDE_EXTRAS}` },
  // Copilot has no import syntax, so the source is inlined.
  { path: '.github/copilot-instructions.md', render: (source) =&gt; `${banner}\n\n${source}` },
];
</code></pre>
<p>Because Claude Code resolves imports, its generated file stays a pointer plus the handful of instructions that only make sense for that tool, which keeps it at around 130 tokens rather than duplicating the whole thing:</p>
<pre><code class="language-markdown">&lt;!-- Generated from AGENTS.md by `npm run sync:context`. Edit AGENTS.md instead. --&gt;

@AGENTS.md

## Claude Code specific

- Use plan mode for any change that touches more than three files, and skip it for a one line fix.
- Delegate codebase exploration to a subagent so the findings come back summarised rather than as
  a hundred file reads in the main context.
</code></pre>
<p>Running the script regenerates both files, and running it again does nothing. This is what you want from something a hook or a CI job will call repeatedly:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/01a74800-b63a-41c9-a68a-0d3aa956d701.png" alt="Figure 1: Terminal showing npm run sync:context writing CLAUD.md and the Copilot instructions file, followed by git status listing both as modified" style="display: block;" width="1920" height="500" loading="lazy">

<h2 id="heading-writing-the-root-file">Writing the Root File</h2>
<p>This is where most of the value is, and it's also where most people go wrong, because the instinct is to write everything down.</p>
<p>Use one editing test on every line you're tempted to add: <strong>would removing this line cause the agent to make a mistake?</strong> If the answer is no, the line is costing you attention budget and buying you nothing, so cut it. Applied honestly, that test removes most of what people put in these files.</p>
<p>Here's the kind of file the test is designed to catch:</p>
<pre><code class="language-markdown"># AGENTS.md

## About this project
This project is a REST API for managing tasks. It was originally built in 2023 by the platform
team and has since been maintained by the core services group. The codebase is written in modern
JavaScript using ES modules.

## Code style
- Use meaningful variable names
- Write clean, maintainable code
- Follow the DRY principle
- Use const instead of var
- Add comments where the code is complex

## Structure
- `src/server.js` contains the server
- `src/router.js` contains the router
- `src/api/tasks.js` contains the task handlers
- `src/services/tasks.js` contains the task service
</code></pre>
<p>Every line there fails the test. The model already knows what <code>const</code> is for, it can see that the file called <code>router.js</code> contains the router, and knowing which team owned the code in 2023 won't change a single decision it makes.</p>
<p>Meanwhile the one thing an agent genuinely can't work out on its own, which is that this project deliberately has no dependencies, is nowhere in the file.</p>
<p>This is the version that ships in the companion repository:</p>
<pre><code class="language-markdown"># AGENTS.md

Task API used as the worked example for a tutorial on managing context files. This file is the
single source of truth for agent instructions, and `CLAUDE.md` plus
`.github/copilot-instructions.md` are generated from it by `npm run sync:context`, so edit this
file and never the generated ones.

## Commands

- Install: nothing to install, the project has zero dependencies
- Run the tests: `npm test`
- Start the server on port 3000: `npm start`
- Check the context files: `npm run lint:context`
- Regenerate the tool specific context files: `npm run sync:context`

## Conventions that are not obvious from the code

- The test runner is the Node built in runner invoked through `node --test`, so do not add Jest,
  Vitest, or any other test dependency to this repository.
- This project stays dependency-free on purpose, so solve problems with the Node standard library
  rather than by adding a package.
- Handlers in `src/api/` return `{ data }` or `{ error: { code, message } }` and never choose an
  HTTP status, because `src/router.js` owns the mapping from error code to status.
- Handlers never touch the store directly, so any logic that reads or writes tasks belongs in
  `src/services/tasks.js`.
- The store is module level state that survives between test cases, so any test file that creates
  a task has to call `resetTasks()` in a `beforeEach` hook.

## Definition of done

Run `npm test` and `npm run lint:context` before you report a task as finished, and paste the
output rather than asserting that it passed.

## Where to look

- Architecture and request flow: `docs/architecture.md`
- Testing conventions and how to add a case: `docs/testing.md`
- Why the store is in memory: `docs/decisions/0001-in-memory-store.md`
- Rules that apply only to the API layer: `src/AGENTS.md`
</code></pre>
<p>Notice what each section is doing. The commands are there because an agent can't reliably guess your script names, and guessing wrong costs a failed run. The conventions are all things that are either invisible from reading the code or actively contradicted by what the model would otherwise assume, and each one states the reason, since a rule with a reason attached survives situations the rule author didn't anticipate. The last section is nothing but paths, which is the on demand layer doing its job.</p>
<p>Rough guidance on what earns its place:</p>
<table>
<thead>
<tr>
<th>Include</th>
<th>Leave out</th>
</tr>
</thead>
<tbody><tr>
<td>Commands the agent can't guess</td>
<td>Anything visible from reading the code</td>
</tr>
<tr>
<td>Conventions that differ from the language default</td>
<td>Standard conventions the model already knows</td>
</tr>
<tr>
<td>The test runner and how to run one test</td>
<td>Detailed API documentation, which should be a link</td>
</tr>
<tr>
<td>Branch naming and pull request etiquette</td>
<td>Information that changes every sprint</td>
</tr>
<tr>
<td>Architectural decisions specific to your project</td>
<td>Long explanations and tutorials</td>
</tr>
<tr>
<td>Environment quirks and required variables</td>
<td>File by file descriptions of the tree</td>
</tr>
<tr>
<td>Non-obvious gotchas</td>
<td>Advice such as "write clean code"</td>
</tr>
</tbody></table>
<h3 id="heading-getting-the-altitude-right">Getting the Altitude Right</h3>
<p>There's a second way to write a bad rule, which is to pitch it at the wrong level of specificity. Anthropic's guidance frames this as finding the right altitude, sitting between hardcoded logic that shatters on the first case it didn't anticipate, and vague encouragement that gives the model nothing to act on.</p>
<pre><code class="language-markdown">Too rigid, and it breaks on the first handler that does not fit:
- Every route handler must be exactly 40 lines and call validate() on line 3.

Too vague, and it changes nothing about what the agent does:
- Write clean, maintainable code.

Right altitude:
- Route handlers parse and validate input, then delegate to a function in `src/services/`.
  Handlers do not touch the store directly. See `src/api/tasks.js` for the pattern to copy.
</code></pre>
<p>The third version tells the agent the shape of the rule, the boundary it must not cross, and where to find a worked example, which is roughly what you would tell a competent new hire on their first day.</p>
<h2 id="heading-scoping-rules-to-a-directory">Scoping Rules to a Directory</h2>
<p>Anything that only matters inside one part of the tree belongs in a nested file, and the test for whether a rule qualifies is simple: would a developer working in a different directory ever need to know this? If not, move it down.</p>
<pre><code class="language-markdown">&lt;!-- src/AGENTS.md --&gt;
# Source layer

Rules below apply to everything under `src/`, and they sit on top of the root `AGENTS.md` rather
than replacing it.

## Adding an endpoint

1. Add the handler to `src/api/tasks.js` following the shape the neighbouring handlers use.
2. Add one entry to the `routes` array in `src/router.js` with its success status.
3. Add a case to `tests/api.test.js` that covers the success path and the failure path.

## Validation

Validators live in `src/lib/validate.js`, they return an array of problem strings rather than
throwing, and they report every failing field instead of stopping at the first one, so a caller can
show the user all of their mistakes at once.
</code></pre>
<p>That validation rule is a good example of something worth writing down, because the code alone doesn't explain itself. An agent reading <code>src/lib/validate.js</code> sees a function returning an array and has no way to know whether that's a deliberate convention or an accident of one implementation, so it might reasonably throw an exception in the next validator it writes:</p>
<pre><code class="language-js">// src/lib/validate.js
export function validateTaskInput(input) {
  if (typeof input !== 'object' || input === null || Array.isArray(input)) {
    return ['body must be a JSON object'];
  }

  const problems = [];

  if (typeof input.title !== 'string' || input.title.trim() === '') {
    problems.push('title is required and must be a non-empty string');
  } else if (input.title.length &gt; TITLE_MAX) {
    problems.push(`title must be ${TITLE_MAX} characters or fewer`);
  }

  if (input.done !== undefined &amp;&amp; typeof input.done !== 'boolean') {
    problems.push('done must be a boolean when present');
  }

  return problems;
}
</code></pre>
<h2 id="heading-pointing-instead-of-inlining">Pointing Instead of Inlining</h2>
<p>The <code>Where to look</code> section of the root file is the cheapest thing in this whole setup. Four lines of paths cost almost nothing to load, and behind them sit several thousand tokens of architecture notes, testing conventions, and decision records that the agent pulls in only when a task needs them.</p>
<p>Architecture decision records are the natural home for the reasoning that would otherwise bloat your root file. The companion repository has one explaining why the task store is a plain <code>Map</code> rather than a database, and its most useful paragraph is the last one:</p>
<pre><code class="language-markdown">An agent working here should not add a database, an ORM, or a persistence layer unless the task
explicitly asks for one, and should treat the missing persistence as a deliberate choice rather than
a gap to fill.
</code></pre>
<p>Without that, an agent asked to "make the API production ready" will helpfully add Postgres. With it, the agent knows the absence is intentional and asks before changing it. That sentence costs you nothing until the day it saves you an afternoon.</p>
<p>The same logic applies to workflows that only come up occasionally. A step by step procedure for adding an endpoint is genuinely useful, and it would be dead weight in a file loaded on every task, so it lives in a skill file that loads when someone actually asks for an endpoint:</p>
<pre><code class="language-markdown">---
name: add-endpoint
description: Add a new endpoint to the task API following the layering this repository uses
---

# Add an endpoint

This workflow loads only when someone asks for a new endpoint, which is why it lives here instead
of in `AGENTS.md` where every session would pay for it.

Read `docs/architecture.md` first if you have not already, then work through these steps in order.

1. Decide which layer owns the new behaviour. Anything that reads or writes tasks belongs in
   `src/services/tasks.js`, and anything about request shape belongs in `src/api/tasks.js`.
2. Add or extend a validator in `src/lib/validate.js` if the endpoint accepts input, returning an
   array of problem strings so the handler can report every failure at once.
3. Add the handler to `src/api/tasks.js`, returning `{ data }` on success and
   `{ error: { code, message } }` on failure, and using an existing error code where one fits.
4. Register the route in the `routes` array in `src/router.js` with the success status it should
   return, and add the error code to `STATUS_BY_ERROR_CODE` if you introduced a new one.
5. Add at least one success case and one failure case to `tests/api.test.js`.
6. Run `npm test` and `npm run lint:context`, then paste both outputs into your summary.

Do not add a dependency, do not introduce a persistence layer, and do not set a status code inside
a handler.
</code></pre>
<h2 id="heading-making-context-files-verifiable">Making Context Files Verifiable</h2>
<p>Everything so far is fairly standard advice, and on its own it has a short shelf life. Context files rot for exactly the same reason documentation rots, which is that nothing breaks when they're wrong. You rename <code>src/services/task.js</code> to <code>src/services/tasks.js</code>, and your context file keeps confidently pointing at a path that no longer exists. You delete the <code>typecheck</code> script, and six months later an agent burns two turns trying to run it. Nobody notices either of those, because nothing in your pipeline is checking.</p>
<p>So put a check in the pipeline and let it fail. The companion repository has a linter in <code>scripts/context-lint.mjs</code> that runs four checks, and it's about 150 lines of dependency-free JavaScript that you can adapt to your own repository in an afternoon.</p>
<p>The first check is a token budget on every file that loads at startup:</p>
<pre><code class="language-javascript">// Loaded at the start of every session whether the task needs them or not. When one of these keeps
// pushing against its ceiling, move the detail into docs/ and leave a path behind.
const ALWAYS_LOADED = [
  { path: 'AGENTS.md', budget: 800 },
  { path: 'CLAUDE.md', budget: 300 },
  { path: '.github/copilot-instructions.md', budget: 900 },
  { path: 'src/AGENTS.md', budget: 400 },
];

// Rough average for English prose. Precision is not the point, catching a file that doubled is.
const CHARS_PER_TOKEN = 4;

const estimateTokens = (text) =&gt; Math.ceil(text.length / CHARS_PER_TOKEN);
</code></pre>
<p>Four characters per token is an approximation rather than a real tokenizer count, and it runs a little optimistic on code heavy files. This is fine because the number you care about is the ceiling. A file creeping from 400 tokens to 800 is the signal, and being off by 8% on the absolute figure changes nothing about how you respond to it.</p>
<p>The second and third checks read your context files as prose and verify that the things they mention are real. Anything in single backticks that looks like a path has to exist on disk, and any npm script has to exist in <code>package.json</code>:</p>
<pre><code class="language-javascript">// Fenced blocks are stripped first so an example inside a snippet is never read as a real reference.
function inlineCodeSpans(text) {
  const prose = text.replace(/```[\s\S]*?```/g, '');
  return [...prose.matchAll(/`([^`\n]+)`/g)].map((match) =&gt; match[1].trim());
}

for (const span of spans) {
  if (looksLikePath(span)) {
    if (!existsSync(join(ROOT, span))) {
      problems.push(`${file} points at a path that does not exist: ${span}`);
    }
    continue;
  }

  const script = span.match(/^npm run ([\w:-]+)$/) ?? span.match(/^npm (test|start)$/);

  if (script &amp;&amp; !scripts.includes(script[1])) {
    problems.push(`${file} mentions an npm script that is not in package.json: ${span}`);
  }
}
</code></pre>
<p>Stripping fenced code blocks before scanning matters more than it looks, since your documentation is full of illustrative examples that were never meant to be real references, and a linter that fails on those gets switched off within a week.</p>
<p>The fourth check reruns the sync script in a dry run mode and fails if any generated file no longer matches <code>AGENTS.md</code>, which catches the teammate who edited <code>CLAUDE.md</code> directly despite the banner.</p>
<p>On a healthy repository the whole thing takes well under a second:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/53a2990f-0664-4946-b782-1ec0e02855d1.png" alt="Figure 2: Terminal output from npm run lint:context showing four context files under their token budgets, 47 references checked across 9 files, generated files in sync, and no problems found." style="display: block;" width="1920" height="1320" loading="lazy">

<p>The interesting output is what happens when something rots. Adding one plausible looking line to <code>AGENTS.md</code> that mentions a script that was deleted and a file that was renamed produces this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/4fa2e8ea-d09f-495d-ae4c-207ebabcaad4.png" alt="Figure 3: Terminal output from npm run lint:context reporting three agent problems: an npm script not in package.json, a path that doesn't exist, and a generated file out of sync with AGENTS.md." style="display: block;" width="1920" height="1280" loading="lazy">

<p>The script exits with a non-zero status, so wiring it into CI takes four lines and means the files can't drift quietly:</p>
<pre><code class="language-yaml"># .github/workflows/ci.yml
      - name: Run the test suite
        run: npm test

      # The context files are checked on every pull request, which is what stops them from
      # drifting away from the code they describe.
      - name: Check the context files
        run: npm run lint:context
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/73bde67f-c80a-48fa-a5c3-6fae2c2826ee.png" alt="Figure 4: GitHub Actions run for the MCF repo showing the verify job succeeding, with the test suite and the context linter both green." style="display: block;" width="2400" height="1000" loading="lazy">

<p>This is the part I would keep if I had to throw away everything else in this tutorial. A mediocre context file that's verifiably true beats a beautifully written one that describes last year's architecture, because the agent has no way to tell the difference and will act on both with equal confidence.</p>
<h2 id="heading-give-the-agent-something-to-verify-against">Give the Agent Something to Verify Against</h2>
<p>There's one more line in that root file worth dwelling on, and it's the definition of done.</p>
<p>An agent stops when the work looks finished, and without a check it can run for itself, "looks finished" is the only signal available to it, which quietly makes you the verification loop. Every mistake then waits for you to notice it.</p>
<p>Naming a command that returns a pass or a fail converts that into something the agent can act on by itself, so it writes the code, runs the check, reads the result, and keeps going until the check passes.</p>
<p>That's why <code>Run npm test and npm run lint:context before you report a task as finished</code> does more for output quality than any amount of style guidance you could write. Asking the agent to paste the output rather than assert success matters too, since reviewing evidence takes you a few seconds and re-running the verification yourself takes minutes.</p>
<p>Instructions in a context file are advice, though, and advice gets lost as the context fills. When something must happen every single time without exception, use a hook, which runs a script at a fixed point in the agent's loop and can't be talked out of it:</p>
<pre><code class="language-json">{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "npm run lint:context --silent"
          }
        ]
      }
    ]
  }
}
</code></pre>
<p>The rule of thumb is that anything advisory belongs in prose, and anything mandatory belongs in a hook or in CI.</p>
<h2 id="heading-checking-whether-it-actually-worked">Checking Whether it Actually Worked</h2>
<p>You shouldn't take any of this on faith, and there's a cheap way to test it on your own repository.</p>
<p>Pick a task with an obviously correct shape, write the prompt down so it stays identical across runs, and run it twice: once on your current branch, and once on a branch where you have deleted the context files. In the companion repository a good candidate is "add a <code>GET /tasks/count</code> endpoint that returns the number of open tasks, with tests".</p>
<p>Then compare the two runs on four points. Did the tests pass without you intervening? How many corrections did you have to make? Did the code follow the existing layering, or did it reach into the store from the handler? Did any new dependency appear?</p>
<p>This is a sample of one rather than a benchmark, and you should treat it as such. But it's enough to tell you whether your files are pulling their weight, and it makes it very obvious which specific rule was missing when something goes wrong.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f3a74bfc4d5973f55c91c8c/2dfe8cce-6039-49ad-9cfc-ca333e55731a.png" alt="Figure 5: Terminal output from npm test showing fourteen passing tests across the routes and the validators" style="display: block;" width="1920" height="1140" loading="lazy">

<h2 id="heading-keeping-the-files-healthy">Keeping the Files Healthy</h2>
<p>Treat these files the way you treat code, which means reviewing them when something breaks rather than on a schedule.</p>
<p>Two diagnostics will cover most of the situations you run into. If the agent keeps violating a rule that's written down, the file is almost certainly too long and the rule is getting lost in the noise. Prune aggressively rather than adding emphasis.</p>
<p>If the agent asks you a question that the file already answers, the wording is ambiguous, so rewrite that line rather than adding a second one next to it.</p>
<p>Beyond that, delete any rule the agent already follows without being told, since the model's defaults improve with every release and a rule that was necessary last year may be dead weight now.</p>
<p>Watch the token budget in the linter output as a rough health metric, because a file that keeps creeping toward its ceiling is telling you that detail needs to move into <code>docs/</code>.</p>
<h2 id="heading-mistakes-worth-avoiding">Mistakes Worth Avoiding</h2>
<p>The most common failure is the kitchen sink file, where every convention anyone ever mentioned gets appended until the file is three thousand tokens and the agent follows roughly half of it. The fix is the removal test applied without sentiment.</p>
<p>The second is duplicating your README into your context file, which doubles the cost of every session while adding nothing, since the two documents have different audiences and the agent can read the README when it needs to.</p>
<p>The third is documenting things the model can see for itself. The giveaway is any line that describes what a file contains rather than what you expect an agent to do about it.</p>
<p>The fourth is writing rules that can't be verified, such as asking for readable code or good performance, which sound reasonable and give the agent no way to tell whether it has complied.</p>
<p>The fifth, and the one that gets teams eventually, is letting each tool keep its own hand-maintained copy. They start out identical, they diverge within a month, and then Cursor and Claude Code are working from contradictory instructions in the same repository. Generate the copies, and check the generation in CI.</p>
<h2 id="heading-where-to-start">Where to Start</h2>
<p>If you only do one thing after reading this, run a token estimate on the context file you already have, and then read it line by line asking whether removing each line would cause a mistake. Most people cut somewhere between a third and a half of the file on the first pass, and notice the agent following the remainder more reliably.</p>
<p>After that, add the pointers so your documentation becomes reachable without being expensive, and put the linter in CI so the whole thing stays honest as the codebase moves underneath it.</p>
<p>The full setup, including the linter, the sync script, the hook, and the CI workflow, is at <a href="https://github.com/Adeniyikayodee/MCF">github.com/Adeniyikayodee/MCF</a>. Clone it, run <code>npm run lint:context</code> to watch it pass, then break something in <code>AGENTS.md</code> and watch it fail.</p>
<p>You can adapt the linter to your own conventions rather than copying it verbatim, since the checks worth running are the ones that match the ways your particular repository tends to drift.</p>
<p>Fork the repository if you want your own copy to experiment in, since a fork gives you a branch point you can modify freely without losing the ability to pull later changes back in. If you would rather be told when those changes land, use the Watch button next to Fork and choose releases or all activity, because that's the control that actually sends you notifications while a fork only captures the code as it stands on the day you take it.</p>
<h3 id="heading-further-reading">Further Reading</h3>
<ul>
<li><p><a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents">Effective context engineering for AI agents</a>, Anthropic</p>
</li>
<li><p><a href="https://code.claude.com/docs/en/best-practices">Best practices for Claude Code</a>, Anthropic</p>
</li>
<li><p><a href="https://agents.md/">The AGENTS.md convention</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Browser-Based PDF to Grayscale Converter Using JavaScript ]]>
                </title>
                <description>
                    <![CDATA[ Many PDF documents contain colorful charts, presentations, marketing materials, scanned pages, or graphics that aren't always ideal for printing or archiving. In some cases, converting a document to g ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-pdf-to-grayscale-converter-javascript/</link>
                <guid isPermaLink="false">6a7f3af6a3acdf7f3110d046</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdfjs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Fri, 14 Aug 2026 15:57:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c167a675-fcdd-4f28-9489-e42c3fe98d9d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Many PDF documents contain colorful charts, presentations, marketing materials, scanned pages, or graphics that aren't always ideal for printing or archiving.</p>
<p>In some cases, converting a document to grayscale reduces distractions, creates printer-friendly versions, lowers printing costs, or prepares files for black-and-white publishing.</p>
<p>A PDF to Grayscale Converter automates this process. Instead of editing every page manually, users can upload a PDF, choose how the grayscale conversion should be applied, preview the results, and download a newly generated document, all from within the browser.</p>
<p>In this tutorial, you'll build a browser-based PDF to Grayscale Converter using JavaScript. Users will be able to upload a PDF, preview every page, adjust the grayscale intensity, choose between multiple conversion modes, select which pages to process, generate a grayscale PDF, preview the final result, rename the output file, and download it without uploading their document to an external server.</p>
<p>We'll use PDF.js to render PDF pages, the HTML Canvas API to manipulate image pixels, and PDF-lib to generate the final downloadable PDF.</p>
<p>By the end of this tutorial, you'll have a complete client-side PDF processing application similar to the one available on All In One Tools.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-this-pdf-to-grayscale-converter-does-and-how-it-works">What This PDF to Grayscale Converter Does and How It Works</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-libraries-used">Libraries Used</a></p>
</li>
<li><p><a href="#heading-creating-the-html-layout">Creating the HTML Layout</a></p>
</li>
<li><p><a href="#heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</a></p>
</li>
<li><p><a href="#heading-building-the-conversion-settings">Building the Conversion Settings</a></p>
</li>
<li><p><a href="#heading-converting-pdf-pages-to-grayscale">Converting PDF Pages to Grayscale</a></p>
</li>
<li><p><a href="#heading-generating-the-final-pdf">Generating the Final PDF</a></p>
</li>
<li><p><a href="#heading-previewing-the-result">Previewing the Result</a></p>
</li>
<li><p><a href="#heading-renaming-and-downloading">Renaming and Downloading</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-to-grayscale-converter-works">Demo: How the PDF to Grayscale Converter Works</a></p>
</li>
<li><p><a href="#heading-performance-tips">Performance Tips</a></p>
</li>
<li><p><a href="#heading-common-mistakes">Common Mistakes</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-this-pdf-to-grayscale-converter-does-and-how-it-works">What This PDF to Grayscale Converter Does and How It Works</h2>
<p>A PDF to Grayscale Converter transforms colorful PDF pages into shades of gray while preserving the document's layout, page dimensions, text placement, and images. Instead of removing content, it recalculates the color of every pixel so the entire page appears in grayscale.</p>
<p>This is useful for creating printer-friendly documents, reducing color distractions, preparing files for monochrome printing, improving consistency across scanned documents, or producing black-and-white versions for review and archival purposes.</p>
<p>In this project, users can upload a PDF, browse through every page, adjust the grayscale intensity, choose between different conversion modes, decide whether all pages or only selected pages should be converted, generate a new grayscale PDF, preview the completed document, rename the output file, and download it directly from the browser.</p>
<p>Behind the scenes, PDF.js renders each PDF page onto an HTML canvas. Once the page has been rendered, JavaScript reads the RGB values for every pixel and calculates a grayscale value using a luminance formula. The updated pixels are written back to the canvas before PDF-lib assembles all processed pages into a brand-new PDF.</p>
<p>A typical pixel contains four values:</p>
<pre><code class="language-javascript">const pixel = {
    red: 180,
    green: 95,
    blue: 40,
    alpha: 255
};
</code></pre>
<p>To convert that pixel into grayscale, JavaScript calculates a single luminance value and applies it equally to the red, green, and blue channels.</p>
<pre><code class="language-javascript">const gray = 0.299 * red + 0.587 * green + 0.114 * blue;
</code></pre>
<p>The resulting pixel becomes:</p>
<pre><code class="language-javascript">pixel.red = gray;
pixel.green = gray;
pixel.blue = gray;
</code></pre>
<p>Repeating this process for every pixel on every selected page creates a new grayscale version of the original PDF while preserving the overall structure of the document.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before writing the conversion logic, let's create a simple project structure.</p>
<p>We'll build everything using HTML, CSS, and JavaScript, together with PDF.js, the Canvas API, and PDF-lib.</p>
<p>Our project structure looks like this:</p>
<pre><code class="language-text">pdf-to-grayscale/
│── index.html
│── style.css
│── script.js
│── pdf.worker.min.js
│── assets/
</code></pre>
<p>Keeping the HTML, styling, and JavaScript separate makes the project easier to maintain as more PDF features are added.</p>
<h2 id="heading-libraries-used">Libraries Used</h2>
<p>The PDF to Grayscale Converter relies on three browser technologies that work together to render PDF pages, process image pixels, and generate a new downloadable document.</p>
<p><strong>PDF.js</strong> renders PDF pages directly inside the browser.</p>
<p>The <strong>HTML Canvas API</strong> provides access to every pixel so JavaScript can convert colors into grayscale.</p>
<p><strong>PDF-lib</strong> creates the final PDF after all selected pages have been processed.</p>
<p>Include the required libraries before loading your application.</p>
<pre><code class="language-html">&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.js"&gt;&lt;/script&gt;
&lt;script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"&gt;&lt;/script&gt;
&lt;script src="script.js"&gt;&lt;/script&gt;
</code></pre>
<p>Configure the PDF.js worker.</p>
<pre><code class="language-javascript">pdfjsLib.GlobalWorkerOptions.workerSrc = "pdf.worker.min.js";
</code></pre>
<p>Using a worker allows PDF rendering to happen in the background without freezing the browser interface.</p>
<h2 id="heading-creating-the-html-layout">Creating the HTML Layout</h2>
<p>The application is divided into four main sections:</p>
<ul>
<li><p>Upload area</p>
</li>
<li><p>PDF preview</p>
</li>
<li><p>Conversion settings</p>
</li>
<li><p>Download section</p>
</li>
</ul>
<p>Create the basic page structure.</p>
<pre><code class="language-html">&lt;section id="uploadSection"&gt;&lt;/section&gt;
&lt;section id="previewSection" hidden&gt;&lt;/section&gt;
&lt;section id="settingsSection" hidden&gt;&lt;/section&gt;
&lt;section id="downloadSection" hidden&gt;&lt;/section&gt;
</code></pre>
<p>Only the upload area is visible when the page first loads. The remaining sections appear after a PDF has been successfully opened.</p>
<h3 id="heading-selecting-the-main-elements">Selecting the Main Elements</h3>
<p>Store references to the elements that will be used throughout the application.</p>
<pre><code class="language-javascript">const uploadSection = document.getElementById("uploadSection");
const previewSection = document.getElementById("previewSection");
const settingsSection = document.getElementById("settingsSection");
const pdfCanvas = document.getElementById("pdfCanvas");
</code></pre>
<p>Using these references makes it easier to update the interface as users move through the conversion process.</p>
<h2 id="heading-uploading-and-previewing-pdfs">Uploading and Previewing PDFs</h2>
<p>The upload area accepts both drag-and-drop and manual file selection.</p>
<p>When a file is selected, first verify that it's a PDF.</p>
<pre><code class="language-javascript">async function uploadPdf(file) {
    if (!file || file.type !== "application/pdf") {
        alert("Please select a PDF file.");
        return;
    }

    await loadPdf(file);
}
</code></pre>
<p>Once validation succeeds, the document is loaded into memory for processing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/5f29ba44-afe7-4f2f-b685-2608b9b8ca55.png" alt="Upload area for selecting a PDF document." style="display: block;" width="1016" height="603" loading="lazy">

<h3 id="heading-loading-the-pdf">Loading the PDF</h3>
<p>Convert the uploaded file into an ArrayBuffer before opening it with PDF.js.</p>
<pre><code class="language-javascript">async function loadPdf(file) {
    const bytes = await file.arrayBuffer();

    pdfDocument = await pdfjsLib.getDocument({
        data: bytes
    }).promise;

    currentPage = 1;
    renderPage(currentPage);
}
</code></pre>
<p>The loaded document is stored so every page can later be converted to grayscale.</p>
<h3 id="heading-rendering-pdf-pages">Rendering PDF Pages</h3>
<p>PDF.js renders one page at a time onto an HTML canvas.</p>
<p>Retrieve the page.</p>
<pre><code class="language-javascript">const page = await pdfDocument.getPage(currentPage);
</code></pre>
<p>Create the viewport.</p>
<pre><code class="language-javascript">const viewport = page.getViewport({
    scale: 1.5
});
</code></pre>
<p>Resize the canvas.</p>
<pre><code class="language-javascript">pdfCanvas.width = viewport.width;
pdfCanvas.height = viewport.height;
</code></pre>
<p>Render the page.</p>
<pre><code class="language-javascript">await page.render({
    canvasContext: pdfCanvas.getContext("2d"),
    viewport
}).promise;
</code></pre>
<p>Once rendering finishes, the selected page appears inside the preview area.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/f1e5d376-b101-4116-9be6-68c4303eb398.png" alt="PDF page preview rendered with PDF.js." style="display: block;" width="892" height="677" loading="lazy">

<h3 id="heading-navigating-between-pages">Navigating Between Pages</h3>
<p>Most PDF documents contain multiple pages, so users need simple navigation controls.</p>
<p>Track the current page.</p>
<pre><code class="language-javascript">let currentPage = 1;
let pdfDocument = null;
</code></pre>
<p>Move to the previous page.</p>
<pre><code class="language-javascript">previousButton.addEventListener("click", async () =&gt; {
    if (currentPage &gt; 1) {
        currentPage--;
        await renderPage(currentPage);
    }
});
</code></pre>
<p>Move to the next page.</p>
<pre><code class="language-javascript">nextButton.addEventListener("click", async () =&gt; {
    if (currentPage &lt; pdfDocument.numPages) {
        currentPage++;
        await renderPage(currentPage);
    }
});
</code></pre>
<p>Update the page indicator.</p>
<pre><code class="language-javascript">pageCounter.textContent = `Page ${currentPage} of ${pdfDocument.numPages}`;
</code></pre>
<p>Users can now browse through the document before deciding how the grayscale conversion should be applied.</p>
<h2 id="heading-building-the-conversion-settings">Building the Conversion Settings</h2>
<p>After the PDF has been loaded and previewed, users can configure how the document should be converted to grayscale. The settings panel allows users to adjust the grayscale intensity, choose a conversion mode, decide which pages should be processed, and start the conversion.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/9fe91d30-e2b7-444d-8ada-f1606b172d1c.png" alt="PDF to Grayscale Converter settings panel showing intensity slider, conversion modes, and page selection options." style="display: block;" width="908" height="633" loading="lazy">

<h3 id="heading-adjusting-grayscale-intensity">Adjusting Grayscale Intensity</h3>
<p>The intensity slider controls how strongly the grayscale effect is applied.</p>
<p>Lower values retain more of the original color, while higher values produce a true grayscale appearance.</p>
<p>Create the slider.</p>
<pre><code class="language-html">&lt;input type="range" id="grayIntensity" min="0" max="100" value="100"&gt;
</code></pre>
<p>Read the selected value.</p>
<pre><code class="language-javascript">const intensity = Number(document.getElementById("grayIntensity").value);
</code></pre>
<p>The selected intensity will later be used when calculating the final grayscale color.</p>
<h3 id="heading-choosing-the-conversion-mode">Choosing the Conversion Mode</h3>
<p>The tool provides multiple grayscale modes for different use cases.</p>
<p>Create the radio buttons.</p>
<pre><code class="language-html">&lt;input type="radio" name="mode" value="standard" checked&gt;
Standard Grayscale
&lt;input type="radio" name="mode" value="threshold"&gt;
Black &amp; White
&lt;input type="radio" name="mode" value="soft"&gt;
Soft Gray
</code></pre>
<p>Retrieve the selected mode.</p>
<pre><code class="language-javascript">const conversionMode = document.querySelector(
    'input[name="mode"]:checked'
).value;
</code></pre>
<p>Each mode uses a different algorithm when processing the canvas pixels.</p>
<h3 id="heading-selecting-the-pages">Selecting the Pages</h3>
<p>Users can convert either the entire document or only selected pages.</p>
<p>Create the page selection controls.</p>
<pre><code class="language-html">&lt;input type="radio" name="pages" value="all" checked&gt;
All Pages
&lt;input type="radio" name="pages" value="custom"&gt;
Specific Pages
&lt;input type="text" id="pageRange" placeholder="e.g., 1, 3-5, 10"&gt;
</code></pre>
<p>Read the selected option.</p>
<pre><code class="language-javascript">const applyMode = document.querySelector(
    'input[name="pages"]:checked'
).value;
</code></pre>
<p>Retrieve the custom page range.</p>
<pre><code class="language-javascript">const pageRange = document.getElementById("pageRange").value.trim();
</code></pre>
<p>When <strong>All Pages</strong> is selected, every page in the PDF is processed. Otherwise, only the pages specified by the user are converted.</p>
<h2 id="heading-converting-pdf-pages-to-grayscale">Converting PDF Pages to Grayscale</h2>
<p>Once the settings have been configured, users can begin the conversion.</p>
<p>Create the action button.</p>
<pre><code class="language-html">&lt;button id="convertPdf"&gt;Convert to Grayscale&lt;/button&gt;
</code></pre>
<p>Start the conversion.</p>
<pre><code class="language-javascript">convertButton.addEventListener("click", async () =&gt; {
    await convertPdf();
});
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/baede49d-a7e5-4ca5-9322-4b041bb6bfb4.png" alt="Convert to Grayscale button below the conversion settings." style="display: block;" width="527" height="86" loading="lazy">

<h3 id="heading-starting-over">Starting Over</h3>
<p>Users can clear the current document and return the application to its initial state.</p>
<p>Create the reset button.</p>
<pre><code class="language-html">&lt;button id="resetTool"&gt;Start Over&lt;/button&gt;
</code></pre>
<p>Reset the application.</p>
<pre><code class="language-javascript">resetTool.addEventListener("click", () =&gt; {
    location.reload();
});
</code></pre>
<p>This removes the current PDF and restores the default settings so another document can be processed.</p>
<h3 id="heading-reading-canvas-pixels">Reading Canvas Pixels</h3>
<p>After a page has been rendered, retrieve its pixel data.</p>
<pre><code class="language-javascript">const imageData = context.getImageData(
    0,
    0,
    canvas.width,
    canvas.height
);
</code></pre>
<p>The pixel information is stored in an array.</p>
<pre><code class="language-javascript">const pixels = imageData.data;
</code></pre>
<p>Each pixel contains four values:</p>
<ul>
<li><p>Red</p>
</li>
<li><p>Green</p>
</li>
<li><p>Blue</p>
</li>
<li><p>Alpha</p>
</li>
</ul>
<p>We'll update the RGB values while leaving the alpha channel unchanged.</p>
<h3 id="heading-converting-colors-to-grayscale">Converting Colors to Grayscale</h3>
<p>The standard grayscale algorithm calculates a luminance value using the red, green, and blue channels.</p>
<p>Loop through every pixel.</p>
<pre><code class="language-javascript">for (let i = 0; i &lt; pixels.length; i += 4) {
    const gray =
        0.299 * pixels[i] +
        0.587 * pixels[i + 1] +
        0.114 * pixels[i + 2];

    pixels[i] = gray;
    pixels[i + 1] = gray;
    pixels[i + 2] = gray;
}
</code></pre>
<p>This formula produces a natural-looking grayscale image because it reflects how the human eye perceives brightness.</p>
<h3 id="heading-applying-the-selected-conversion-mode">Applying the Selected Conversion Mode</h3>
<p>Different conversion modes use different pixel calculations.</p>
<p>For example, the <strong>Black &amp; White (Threshold)</strong> mode converts each pixel into either pure black or pure white.</p>
<pre><code class="language-javascript">const threshold = 128;
const color = gray &gt;= threshold ? 255 : 0;
pixels[i] = color;
pixels[i + 1] = color;
pixels[i + 2] = color;
</code></pre>
<p>The <strong>Soft Gray</strong> mode blends the original color with the grayscale value to create a less aggressive effect.</p>
<pre><code class="language-javascript">const softGray =
    (gray * 0.6) +
    (pixels[i] * 0.4);

pixels[i] = softGray;
pixels[i + 1] = softGray;
pixels[i + 2] = softGray;
</code></pre>
<p>Once the selected mode has been applied, write the updated pixels back to the canvas.</p>
<pre><code class="language-javascript">context.putImageData(
    imageData,
    0,
    0
);
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/a9d004ac-c519-463e-91a7-d98abfdeec62.png" alt="PDF page preview after applying the grayscale conversion." style="display: block;" width="905" height="697" loading="lazy">

<h3 id="heading-processing-the-selected-pages">Processing the Selected Pages</h3>
<p>Instead of processing the entire document every time, convert only the pages selected by the user.</p>
<p>Loop through the page range.</p>
<pre><code class="language-javascript">for (let page = startPage; page &lt;= endPage; page++) {
    await processPage(page);
}
</code></pre>
<p>Each processed page is temporarily stored before the final PDF is created.</p>
<h2 id="heading-generating-the-final-pdf">Generating the Final PDF</h2>
<p>Create a new PDF document.</p>
<pre><code class="language-javascript">const outputPdf = await PDFLib.PDFDocument.create();
</code></pre>
<p>Convert the processed canvas into an image.</p>
<pre><code class="language-javascript">const imageBytes = await canvasToBytes(pdfCanvas);
</code></pre>
<p>Embed the image into the PDF.</p>
<pre><code class="language-javascript">const image = await outputPdf.embedPng(imageBytes);
</code></pre>
<p>Create a new page.</p>
<pre><code class="language-javascript">const page = outputPdf.addPage([
    image.width,
    image.height
]);
</code></pre>
<p>Draw the processed image.</p>
<pre><code class="language-javascript">page.drawImage(image, {
    x: 0,
    y: 0,
    width: image.width,
    height: image.height
});
</code></pre>
<p>Repeat this process for every selected page until the new grayscale document is complete.</p>
<h3 id="heading-saving-the-generated-pdf">Saving the Generated PDF</h3>
<p>After all pages have been processed, save the completed document.</p>
<pre><code class="language-javascript">const pdfBytes = await outputPdf.save();
</code></pre>
<p>Create a downloadable PDF file.</p>
<pre><code class="language-javascript">generatedPdfBlob = new Blob([pdfBytes], {
    type: "application/pdf"
});
</code></pre>
<p>The grayscale PDF is now ready for preview, renaming, and downloading.</p>
<h2 id="heading-previewing-the-result">Previewing the Result</h2>
<p>Before downloading the converted document, it's useful to let users review the final output. This allows them to verify that the selected pages have been converted correctly and that the grayscale appearance meets their expectations.</p>
<p>Load the generated PDF using PDF.js.</p>
<pre><code class="language-javascript">let finalPdf = null;

async function showResult() {
    const bytes = await generatedPdfBlob.arrayBuffer();

    finalPdf = await pdfjsLib.getDocument({
        data: bytes
    }).promise;

    renderResultPage(1);
}
</code></pre>
<p>Render the selected page.</p>
<pre><code class="language-javascript">async function renderResultPage(pageNumber) {
    const page = await finalPdf.getPage(pageNumber);

    const viewport = page.getViewport({
        scale: 1.5
    });

    resultCanvas.width = viewport.width;
    resultCanvas.height = viewport.height;

    await page.render({
        canvasContext: resultCanvas.getContext("2d"),
        viewport
    }).promise;
}
</code></pre>
<p>Users can browse through every converted page before downloading the PDF.</p>
<h2 id="heading-renaming-and-downloading">Renaming and Downloading</h2>
<p>Before saving the converted PDF, users can provide a custom filename.</p>
<p>Create the filename input.</p>
<pre><code class="language-html">&lt;input
    type="text"
    id="outputFilename"
    value="grayscale-document.pdf"
&gt;
</code></pre>
<p>Retrieve the filename.</p>
<pre><code class="language-javascript">function getFilename() {
    let filename = outputFilename.value.trim();

    if (!filename) {
        filename = "grayscale-document.pdf";
    }

    if (!filename.toLowerCase().endsWith(".pdf")) {
        filename += ".pdf";
    }

    return filename;
}
</code></pre>
<p>Display information about the generated PDF.</p>
<pre><code class="language-javascript">pageCount.textContent = `${finalPdf.numPages} Pages`;
fileSize.textContent = formatFileSize(generatedPdfBlob.size);
</code></pre>
<p>Download the completed PDF.</p>
<pre><code class="language-javascript">downloadButton.addEventListener("click", () =&gt; {
    const url = URL.createObjectURL(generatedPdfBlob);

    const link = document.createElement("a");
    link.href = url;
    link.download = getFilename();
    link.click();

    URL.revokeObjectURL(url);
});
</code></pre>
<p>Everything happens locally inside the browser, allowing users to keep their documents private throughout the conversion process.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/70faf03f-7d15-4c76-898d-088db8bb7448.png" alt="Download section showing the output filename, page count, file size, and Download button." style="display: block;" width="948" height="435" loading="lazy">

<h2 id="heading-demo-how-the-pdf-to-grayscale-converter-works">Demo: How the PDF to Grayscale Converter Works</h2>
<p>Let's walk through the complete workflow.</p>
<h3 id="heading-step-1-upload-the-pdf">Step 1: Upload the PDF</h3>
<p>Users begin by dragging a PDF into the upload area or clicking <strong>Select PDF</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/9b6e9622-fe22-4e19-a6fd-23cb28defad9.png" alt="Upload area for selecting a PDF document." style="display: block;" width="1016" height="603" loading="lazy">

<h3 id="heading-step-2-preview-the-document">Step 2: Preview the Document</h3>
<p>PDF.js renders the uploaded document page by page, allowing users to review the file before conversion.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/93bd2249-88d1-4056-b3dd-c719e9853c70.png" alt="PDF ready to be converted" style="display: block;" width="905" height="697" loading="lazy">

<h3 id="heading-step-3-configure-the-conversion">Step 3: Configure the Conversion</h3>
<p>Users adjust the grayscale intensity, choose a conversion mode, and decide whether to process all pages or only selected pages.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/bcd8f197-ead0-4206-ac81-e03b85ff75c8.png" alt="Grayscale conversion settings panel." style="display: block;" width="908" height="633" loading="lazy">

<h3 id="heading-step-4-convert-the-pdf">Step 4: Convert the PDF</h3>
<p>Click <strong>Convert to Grayscale</strong> to begin processing the selected pages.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/d34647d8-6df4-468b-b6db-1b2c5ebb3dbb.png" alt="Convert to Grayscale button with Start Over option." style="display: block;" width="527" height="86" loading="lazy">

<h3 id="heading-step-5-review-the-converted-document">Step 5: Review the Converted Document</h3>
<p>After processing is complete, the application displays a preview of the generated grayscale PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8dbde4e8-447e-4704-bfd4-9a91244149e5.png" alt="Greyscale PDF preview after conversion" style="display: block;" width="892" height="677" loading="lazy">

<h3 id="heading-step-6-rename-and-download">Step 6: Rename and Download</h3>
<p>Finally, users can rename the output file, review the page count and file size, and download the converted PDF.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/3703ca08-3df4-4fd9-afbc-9a75b1b369bb.png" alt="Final download section with filename, page count, file size, and Download button." style="display: block;" width="948" height="435" loading="lazy">

<h2 id="heading-performance-tips">Performance Tips</h2>
<p>Large PDF files require more processing time because every page must be rendered and converted. Processing only the selected pages helps improve performance.</p>
<pre><code class="language-javascript">for (let page = startPage; page &lt;= endPage; page++) {
    await processPage(page);
}
</code></pre>
<p>After downloading the file, release temporary resources to reduce memory usage.</p>
<pre><code class="language-javascript">URL.revokeObjectURL(downloadUrl);
</code></pre>
<p>These simple optimizations help keep the converter responsive when working with large multi-page PDF documents.</p>
<h2 id="heading-common-mistakes">Common Mistakes</h2>
<p>One common mistake is converting the same page multiple times without first rendering the original page again. Always start with the original PDF page before applying another grayscale conversion.</p>
<pre><code class="language-javascript">await renderPage(currentPage);
</code></pre>
<p>Another issue is allowing users to specify invalid page numbers.</p>
<pre><code class="language-javascript">if (pageNumber &lt; 1 || pageNumber &gt; pdfDocument.numPages) {
    return;
}
</code></pre>
<p>Finally, remember that higher output quality usually produces larger PDF files. Choosing the appropriate quality setting helps balance image clarity and file size.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF to Grayscale Converter using JavaScript.</p>
<p>You learned how to upload and preview PDF documents, render pages with PDF.js, convert colorful pages into grayscale using the Canvas API, process selected pages, generate a new PDF with PDF-lib, preview the completed document, rename the output file, and download it directly from the browser.</p>
<p>Because all processing happens locally, users can convert PDF documents to grayscale without uploading sensitive files to an external server.</p>
<p>You can explore the complete workflow using the <a href="https://allinonetools.net/pdf-to-grayscale-converter/?utm_source=chatgpt.com">PDF to Grayscale Converter</a>.</p>
<p>From here, you can extend the project with additional features such as sepia conversion, brightness and contrast controls, custom grayscale presets, batch PDF processing, or support for additional image filters.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Create a Scalable KYC Onboarding Flow in React with Shadcn UI ]]>
                </title>
                <description>
                    <![CDATA[ Every B2B SaaS product with a compliance requirement (like banking, lending, payroll, or crypto) hits the same wall early on: before you can let a business use your platform, you need to verify who th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-create-a-kyc-onboarding-flow-with-shadcn-ui/</link>
                <guid isPermaLink="false">6a7e08ac157d6ad1bb83d9cf</guid>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Vaibhav Gupta ]]>
                </dc:creator>
                <pubDate>Thu, 13 Aug 2026 18:10:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c86a7b7f-9199-499c-8f61-7a1fda09f519.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every B2B SaaS product with a compliance requirement (like banking, lending, payroll, or crypto) hits the same wall early on: before you can let a business use your platform, you need to verify who they are.</p>
<p>That means collecting a business type, pulling in registration documents, and showing the user where their verification stands, all without making onboarding feel like a customs form.</p>
<p>This article breaks down a working three step KYC (Know Your Customer) flow built with Shadcn UI: a stepper for progress, a radio group for account type, a file upload zone for documents, and an alert for verification status. You'll see the actual component code, not a simplified stand-in, along with the reasoning behind each decision.</p>
<p>You can try the finished flow at <a href="http://onboarding-kyc-flow.vercel.app"><strong>onboarding-kyc-flow.vercel.app</strong></a>. Click through it once before reading on, as it makes the code below easier to follow. And it also comes in dark and light mode.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-youre-building">What You're Building</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-radix-ui-vs-base-ui-which-primitives-this-flow-uses">Radix UI vs Base UI: Which Primitives this Flow Uses</a></p>
</li>
<li><p><a href="#heading-scaffolding-the-flow-with-v0-and-an-mcp-server">Scaffolding the Flow with v0 and an MCP Server</a></p>
</li>
<li><p><a href="#heading-step-1-account-type-with-a-radio-group">Step 1: Account Type with a Radio Group</a></p>
</li>
<li><p><a href="#heading-step-2-document-upload-with-drag-and-drop">Step 2: Document Upload with Drag and Drop</a></p>
</li>
<li><p><a href="#heading-step-3-verification-status-with-an-alert">Step 3: Verification Status with an Alert</a></p>
</li>
<li><p><a href="#heading-adding-a-stepper-to-the-flow">Adding a Stepper to the Flow</a></p>
</li>
<li><p><a href="#heading-small-details-that-make-it-feel-finished">Small Details that Make it Feel Finished</a></p>
</li>
<li><p><a href="#heading-accessibility-notes">Accessibility notes</a></p>
</li>
<li><p><a href="#heading-key-concepts-recap">Key Concepts Recap</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before working through this flow, you should be comfortable with React function components and hooks, specifically <code>useState</code>, <code>useRef</code>, and <code>useEffect</code>.</p>
<p>You should have:</p>
<ul>
<li><p>A Next.js project with the App Router and shadcn/ui already initialized, since this article doesn't cover that initial setup.</p>
</li>
<li><p>A v0 account is optional. You can also use Bolt or Lovable, which support the same shadcn MCP prompt feature.</p>
</li>
</ul>
<h2 id="heading-what-youre-building">What You're Building</h2>
<p>The flow has three steps:</p>
<ol>
<li><p><strong>Account type:</strong> The user picks Startup, Enterprise, or Government. This decision drives the rest of the experience. It's shown back to the user as a confirmation line, and would typically decide which workspace defaults get applied.</p>
</li>
<li><p><strong>Document upload:</strong> The user drags in a business registration document, a tax return, or a company registry export, in PDF or CSV format.</p>
</li>
<li><p><strong>Verification status:</strong> The user sees a live status: checking in progress, then either verified or an issue that needs attention.</p>
</li>
</ol>
<h2 id="heading-project-structure">Project Structure</h2>
<p>The project is a standard Next.js app with <a href="https://shadcnspace.com/"><strong>shadcn/ui</strong></a> already initialized. Here's the top-level layout:</p>
<pre><code class="language-javascript">onboarding-kyc-flow/
├── .vercel/
├── app/
├── components/
├── lib/
├── public/
├── .env.development.local
├── .gitignore
├── components.json
├── next-env.d.ts
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── tsconfig.json
└── tsconfig.tsbuildinfo
</code></pre>
<p><code>components.json</code> is the file the shadcn CLI reads to know where your components live and which style and primitives you're using. <code>components/</code> holds the shared UI pieces (Alert, Badge, Button, Card, Progress, RadioGroup, Separator) that the flow is built from. <code>lib/utils.ts</code> provides the <code>cn</code> helper used throughout the flow to combine conditional class names. <code>app/</code> holds the page itself, shown in full below.</p>
<h2 id="heading-radix-ui-vs-base-ui-which-primitives-this-flow-uses">Radix UI vs Base UI: Which Primitives this Flow Uses</h2>
<p><a href="https://shadcnspace.com/components"><strong>Shadcn components</strong></a> aren't tied to one underlying primitive library. Most of the ecosystem defaults to Radix UI, but Base UI has become a solid alternative, and it's what this flow is built on.</p>
<p>The underlying primitive library can affect how a component behaves and how you work with it in your project. If you're pulling components from a set like Shadcn UI, check which primitive library it targets before mixing components from different sources.</p>
<p>Mixing Radix-based and Base UI-based components generally works, but it means using two different unstyled primitive libraries in the same project. You can <a href="https://shadcnspace.com/blog/radix-ui-vs-base-ui"><strong>compare Radix UI and Base UI here</strong></a>.</p>
<h2 id="heading-scaffolding-the-flow-with-v0-and-an-mcp-server">Scaffolding the Flow with v0 and an MCP Server</h2>
<p>An MCP (Model Context Protocol) server exposes a component library to an AI coding assistant as a set of callable tools. Instead of the assistant guessing at component names and props from training data, it queries the server for the real, current API.</p>
<p>This matters here specifically, since there are now several shadcn-style component sets with similar names and different props.</p>
<p>The Shadcn Components library publishes an MCP server for its free set, connected to v0 by following its <a href="https://shadcnspace.com/docs/getting-started/mcp-server-docs"><strong>getting started guide</strong></a>. The video below covers the connection step by step. The same generated output can also be copied into Lovable or Bolt through their copy prompt feature, so the workflow isn't locked to one AI builder.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/ymTlzbkvvPk" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

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

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

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

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

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

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

<p>This project is open source, and you can easily download the zip and if you like. Please consider giving it a star.</p>
<ul>
<li><a href="https://github.com/vaibhavsudo/onboarding-kyc-flow"><strong>Github Repo</strong></a></li>
</ul>
<h2 id="heading-accessibility-notes">Accessibility notes</h2>
<ul>
<li><p>The stepper's <code>nav</code> element has an <code>aria-label</code>, and the current step carries <code>aria-current="step"</code>, so assistive technology can identify progress without relying on visual position alone.</p>
</li>
<li><p>The radio group sits inside a <code>fieldset</code> with a screen-reader-only <code>legend</code>, grouping the three account types as one decision.</p>
</li>
<li><p>Icons throughout (<code>Check</code>, <code>AlertCircle</code>, <code>UploadCloud</code>, and so on) carry <code>aria-hidden="true"</code>, since they're decorative next to text that already states the same information. This stops screen readers from announcing redundant icon labels.</p>
</li>
<li><p>The remove-file button has an explicit <code>aria-label</code>, since its visible content is an icon only, with no text.</p>
</li>
</ul>
<h2 id="heading-key-concepts-recap">Key Concepts Recap</h2>
<ul>
<li><p>Account type comes first because it's the one decision most likely to affect the rest of the flow, and it's kept in state at the page level, not inside the radio group itself.</p>
</li>
<li><p>File validation runs in a shared function used by both the drag-and-drop path and the click-to-browse path, so both paths apply the same PDF/CSV validation.</p>
</li>
<li><p>Verification status is communicated with both words (the alert) and a rough sense of progress (the progress bar), since either one alone leaves out part of the picture.</p>
</li>
<li><p>The stepper is a pure display component driven by a single <code>step</code> value from its parent. The logic for whether the user can advance lives outside it, not inside it.</p>
</li>
<li><p>Small, non-technical details (like a security note, a confirmation line, or a theme toggle) do as much for how finished a flow feels as any of the four core components.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>None of the four components in this flow are complicated individually. What makes a KYC flow work is the decisions around them: which choice comes first, where validation actually runs, and how honestly the interface talks to the user while something outside their control is being checked.</p>
<p>Whether the first draft comes from typing every line by hand or from scaffolding it with an MCP server and v0, that's the part worth spending time getting right before it ships.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://shadcnspace.com/components"><strong>Shadcn Components</strong></a>, the free component set used in this flow</p>
</li>
<li><p><a href="https://shadcnspace.com/"><strong>ShadcnSpace</strong></a>, the base library these components extend</p>
</li>
<li><p><a href="https://shadcnspace.com/mcp"><strong>MCP server walkthrough</strong></a></p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current"><strong>MDN: ARIA current attribute</strong></a></p>
</li>
<li><p><a href="https://modelcontextprotocol.io/"><strong>Model Context Protocol specification</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
