<?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[ Web Development - 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[ Web Development - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Thu, 28 May 2026 04:41:33 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/web-development/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build AI Apps in the Browser with TensorFlow.js and WebGPU ]]>
                </title>
                <description>
                    <![CDATA[ Most developers think of AI the same way: you send data to a server, the server thinks, you get a response back. That mental model made sense for a long time. It still makes sense for a lot of use cas ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-ai-apps-in-the-browser-with-tensorflow-js-and-webgpu/</link>
                <guid isPermaLink="false">6a1706d0badcd8afcb00415d</guid>
                
                    <category>
                        <![CDATA[ Programming Tips ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ WebAssembly ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TensorFlow ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #chrome_devtools ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayantunji Timilehin ]]>
                </dc:creator>
                <pubDate>Wed, 27 May 2026 14:59:28 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/87141e6b-7529-4278-a2fa-ee8e4d9f9062.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most developers think of AI the same way: you send data to a server, the server thinks, you get a response back. That mental model made sense for a long time. It still makes sense for a lot of use cases.</p>
<p>But there’s a quiet shift happening inside the browser environment that a lot of engineers are completely missing out on.</p>
<p>The modern browser isn’t just a glorified engine for rendering HTML and CSS anymore. It’s turning into a full-blown runtime for local intelligence. We’ve reached a point where you can ship raw machine learning models straight to a user's device and run inference completely client-side. No server trips, no API keys to protect, and once those initial assets load, zero dependency on an internet connection.</p>
<p>This is the reality of Web AI. If you're building for the web today, understanding this paradigm shift is easily one of the most valuable skills you can add to your stack.</p>
<p>In this guide, we’re going to pull back the curtain on how Web AI actually operates under the hood, break down the browser technology stack making it possible, and build a real, working image classifier using Teachable Machine and TensorFlow.js. Along the way, we’ll also set up a live benchmark so you can watch exactly how WebGL and WebGPU stack up against each other in real-time execution speeds.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along with this tutorial, you should have:</p>
<ul>
<li><p>A working knowledge of JavaScript</p>
</li>
<li><p>Basic familiarity with HTML and how the browser works</p>
</li>
<li><p>Google Chrome installed (required for WebGPU support and Chrome's built-in AI APIs)</p>
</li>
<li><p>A code editor like VS Code with the Live Server extension installed (recommended for running the demo locally)</p>
</li>
</ul>
<p>No prior machine learning experience is required.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-web-ai">What is Web AI?</a></p>
</li>
<li><p><a href="#heading-browser-ai-vs-cloud-ai">Browser AI vs Cloud AI</a></p>
</li>
<li><p><a href="#heading-the-technology-stack">The Technology Stack</a></p>
</li>
<li><p><a href="#heading-how-to-build-ai-in-the-browser">How to Build AI in the Browser</a></p>
</li>
<li><p><a href="#heading-chromes-built-in-ai-apis">Chrome's Built-in AI APIs</a></p>
</li>
<li><p><a href="#heading-where-web-ai-is-headed">Where Web AI Is Headed</a></p>
</li>
<li><p><a href="#heading-what-you-learned">What You Learned</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-what-is-web-ai">What is Web AI?</h2>
<p>Instead of sending data off to a distant cloud server, Web AI lets you run machine learning models directly on the user’s device inside their browser. It uses standard web tech like JavaScript, WebAssembly, and WebGPU to handle all the heavy lifting right then and there.</p>
<p>The simplest definition: <strong>intelligence that runs in the browser, without sending your data anywhere.</strong></p>
<p>Most of us already interact with on-device AI every day without realizing it. Think about unlocking an iPhone. The second you lift it, Face ID maps out roughly 30,000 infrared points, feeds that data through a neural network living on Apple's local silicon, matches it against an encrypted embedding, and opens the phone. The whole process takes milliseconds and happens entirely offline.</p>
<p>Browser-based AI works on that exact same core architecture. The only real difference is that we're building on top of shared web standards rather than native hardware APIs. When you spin up a face-tracking model using TensorFlow.js or MediaPipe in Chrome, you're running that exact same pipeline:</p>
<pre><code class="language-plaintext">Camera input → Local ML model → Local decision
</code></pre>
<p>No round trip. No server. The browser is your Neural Engine.</p>
<h2 id="heading-browser-ai-vs-cloud-ai">Browser AI vs Cloud AI</h2>
<p>There’s no right or wrong answer here. It just depends on what you’re trying to build. Both approaches have their pros and cons, so it’s just a matter of picking the tool that fits your specific use case.</p>
<table>
<thead>
<tr>
<th></th>
<th>Browser AI (Client-Side)</th>
<th>Cloud AI (Server-Side)</th>
</tr>
</thead>
<tbody><tr>
<td>Internet required</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Latency</td>
<td>Near-zero</td>
<td>Depends on network</td>
</tr>
<tr>
<td>Privacy</td>
<td>Data stays on device</td>
<td>Data leaves the device</td>
</tr>
<tr>
<td>Model size</td>
<td>Small to medium</td>
<td>As large as you need</td>
</tr>
<tr>
<td>Cost at inference time</td>
<td>Free</td>
<td>Per token or per request</td>
</tr>
</tbody></table>
<p><strong>Use browser AI when:</strong></p>
<ul>
<li><p>You need split-second speed for things like tracking gestures or detecting objects live on a webcam</p>
</li>
<li><p>The app has to work offline (whether it's a PWA or just needs to survive spotty internet)</p>
</li>
<li><p>Privacy is a hard requirement to keep sensitive data like medical inputs, biometrics, or financial information strictly local</p>
</li>
<li><p>You want to reduce or eliminate API costs on high-frequency, lightweight predictions</p>
</li>
</ul>
<p><strong>Use cloud AI when:</strong></p>
<ul>
<li><p>You need large models like GPT-4, Gemini Pro, or Stable Diffusion</p>
</li>
<li><p>You need centralized model updates, A/B testing, or user analytics</p>
</li>
<li><p>You require serious GPU or TPU compute power</p>
</li>
</ul>
<p>Most production systems actually use a mix of both. Take Google Photos: it handles face detection right on your device so it’s fast and private, but leaves the heavier categorization work for the cloud. Or think of a modern web app that might use TensorFlow.js locally to classify images instantly, but calls the Gemini API when it needs deeper language processing.</p>
<p>This hybrid setup, keeping lightweight intelligence at the edge and heavy compute in the cloud, is usually the sweet spot for most apps.</p>
<h2 id="heading-the-technology-stack">The Technology Stack</h2>
<p>Browser AI isn’t just a single tool – it’s a stacked layer of technologies. Knowing how these layers fit together makes it a lot easier to choose your setup and navigate the trade-offs.</p>
<h3 id="heading-tensors">Tensors</h3>
<p>Before jumping into any ML framework, you need to understand tensors. Not deeply, just enough of a handle on them so you don't get blindsided by tensor shape errors, because they will happen and they can be tricky to debug.</p>
<p>Think of a tensor as a multi-dimensional grid of numbers. Whether your model is processing images, audio, or text, everything gets converted into this format first. Models only speak numbers, and tensors are the containers that hold them.</p>
<pre><code class="language-plaintext">A single number       → 0D tensor (scalar):  42
A list of numbers     → 1D tensor (vector):  [0.2, 0.8, 0.5]
A table of numbers    → 2D tensor (matrix):  [[1,2,3],[4,5,6]]
An image              → 3D tensor:           shape [224, 224, 3]
A batch of images     → 4D tensor:           shape [32, 224, 224, 3]
</code></pre>
<p>Models accept inputs in specific shapes. If your tensor shape doesn't match the model's expected input, your code breaks. That's why understanding dimensions is practical, not just theoretical.</p>
<p>TensorFlow is literally named after this concept. Tensor + Flow = tensors flowing through neural networks.</p>
<p>Here's how you create tensors in TensorFlow.js:</p>
<pre><code class="language-javascript">// 1D tensor — a list of values
const scores = tf.tensor([0.1, 0.7, 0.2]);

// 3D tensor — a single image (height x width x RGB channels)
const image = tf.tensor([
  [[255, 0, 0], [0, 255, 0]],
  [[0, 0, 255], [255, 255, 0]]
]);

// 4D tensor — a batch of 32 images
const batch = tf.zeros([32, 224, 224, 3]);
</code></pre>
<h3 id="heading-tensorflowjs">TensorFlow.js</h3>
<p>TensorFlow.js is Google's JavaScript version of TensorFlow. It lets you run pre-trained models right in the browser and, if you really want to, train new ones completely client-side.</p>
<p>The most important concept in TensorFlow.js is the backend, the hardware your model actually runs on. You can switch between backends depending on what the user's device supports, and it makes a significant difference to performance.</p>
<pre><code class="language-javascript">await tf.setBackend('webgpu');  // fastest — true GPU compute
await tf.setBackend('webgl');   // very fast — GPU via graphics shaders
await tf.setBackend('wasm');    // fast — near-native CPU speed
await tf.setBackend('cpu');     // slowest — plain JavaScript on CPU

await tf.ready();
console.log('Running on:', tf.getBackend());
</code></pre>
<p>In practice, you want to try the fastest available backend and fall back gracefully if a user's browser doesn't support it:</p>
<pre><code class="language-javascript">const backends = ['webgpu', 'webgl', 'wasm', 'cpu'];

for (const backend of backends) {
  try {
    await tf.setBackend(backend);
    await tf.ready();
    console.log('Using backend:', backend);
    break;
  } catch {
    continue;
  }
}
</code></pre>
<h3 id="heading-webassembly">WebAssembly</h3>
<p>WebAssembly (WASM) basically lets code written in C++ or Rust run inside the browser at near-native speeds. When it comes to AI, this is a big deal because heavy math operations like tensor calculations, data preprocessing, and running compressed models happen way faster in WASM than they ever could in standard JavaScript.</p>
<p>Under the hood, TensorFlow.js's WASM backend is using a compiled C++ runtime. If you're running compressed models on a device's CPU, switching to the WASM backend can make your app anywhere from 2 to 10 times faster than just sticking with regular JavaScript.</p>
<pre><code class="language-javascript">await tf.setBackend('wasm');
await tf.ready();
</code></pre>
<h3 id="heading-webgl-and-webgpu">WebGL and WebGPU</h3>
<p>This is where browser AI performance gets interesting.</p>
<p><strong>WebGL</strong> was originally built for 3D graphics. But developers discovered that the parallel computation that GPUs use for rendering is exactly the kind of parallel computation neural networks need.</p>
<p>TensorFlow.js's WebGL backend encodes tensor operations as graphics shader programs and runs them on the GPU. It works well, but it's a workaround, as WebGL was never designed for this kind of work.</p>
<p><strong>WebGPU</strong> is what was actually designed for the job. It launched in Chrome back in April 2023 after six years of collaboration between Apple, Google, Mozilla, Intel, and Microsoft.</p>
<p>Instead of just handling graphics, it's a modern API built from the ground up for general-purpose computing. When it comes to running AI models, it can be 2 to 3 times faster than WebGL, which means you can actually run significantly larger models right in the browser.</p>
<p>Here's how to check for WebGPU support and use it:</p>
<pre><code class="language-javascript">if ('gpu' in navigator) {
  console.log('WebGPU is supported');
  await tf.setBackend('webgpu');
} else {
  console.warn('WebGPU not available, falling back to WebGL');
  await tf.setBackend('webgl');
}

await tf.ready();
</code></pre>
<p>To enable WebGPU in Chrome for development, go to:</p>
<pre><code class="language-plaintext">chrome://flags/#enable-unsafe-webgpu → Enable → Restart Chrome
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/66058baaeb0049c5f549a186/77964ba9-2db1-4011-b6fe-17b47f48688b.png" alt="Enable web-gpu in chrome" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The performance progression across backends looks like this:</p>
<table>
<thead>
<tr>
<th>Backend</th>
<th>What's happening under the hood</th>
<th>Relative speed</th>
</tr>
</thead>
<tbody><tr>
<td>cpu</td>
<td>Plain JavaScript on CPU</td>
<td>Slow</td>
</tr>
<tr>
<td>wasm</td>
<td>Compiled C++ on CPU</td>
<td>Fast</td>
</tr>
<tr>
<td>webgl</td>
<td>GPU via graphics shaders</td>
<td>Very fast</td>
</tr>
<tr>
<td>webgpu</td>
<td>GPU via compute shaders</td>
<td>Fastest</td>
</tr>
</tbody></table>
<h3 id="heading-mediapipe">MediaPipe</h3>
<p>MediaPipe is Google's framework for real-time perception tasks like hand tracking, face mesh detection, pose estimation, and object detection. Think of it as plug-and-play AI for anything that involves a camera.</p>
<p>You don't build these models yourself – you just import them and use them. MediaPipe is what actually powers the background blur in Google Meet and the visual filters in YouTube. Under the hood, it runs on TensorFlow.js and WebAssembly to keep everything moving fast.</p>
<p>You can try all MediaPipe models interactively before writing any code at <a href="https://mediapipe-studio.webapps.google.com/home">MediaPipe Studio</a>.</p>
<h2 id="heading-how-to-build-ai-in-the-browser">How to Build AI in the Browser</h2>
<h3 id="heading-step-1-train-a-model-with-teachable-machine">Step 1: Train a Model with Teachable Machine</h3>
<p><a href="https://teachablemachine.withgoogle.com">Teachable Machine</a> is Google's no-code tool for building models. It lets you create custom images, audio, or pose classifiers right from your webcam without needing any machine learning experience. Once you're done, you can export them as TensorFlow.js models that are completely ready to drop straight into your app.</p>
<p>Here's how to get started:</p>
<ol>
<li><p>Go to <a href="https://teachablemachine.withgoogle.com">teachablemachine.withgoogle.com</a></p>
</li>
<li><p>Choose Image Project, standard image model.</p>
</li>
<li><p>Create two or more classes. "Thumbs Up" and "Thumbs Down" is a simple starting point</p>
</li>
<li><p>Record examples for each class using your webcam</p>
</li>
<li><p>Click <strong>Train Model</strong> — training happens entirely in your browser</p>
</li>
<li><p>Click <strong>Export Model</strong> and choose <strong>TensorFlow.js</strong></p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/66058baaeb0049c5f549a186/8ec77493-cf3a-4c05-add0-3140185cc5aa.png" alt="Train with teachable machine" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>When you export, you get three files:</p>
<ul>
<li><p><code>model.json</code>: The model architecture: layers, input/output shapes, and paths to the weights</p>
</li>
<li><p><code>weights.bin</code>: The trained weights stored as binary data</p>
</li>
<li><p><code>metadata.json</code>: Class labels, input size, and inference configuration</p>
</li>
</ul>
<h4 id="heading-a-note-on-training-data-quality">A note on training data quality</h4>
<p>Teachable Machine relies on supervised learning. You give the model labeled examples, and it figures out the underlying patterns. When you're gathering your data, two things matter way more than the sheer number of pictures you take:</p>
<ul>
<li><p><strong>Balance:</strong> If one class has significantly more examples than another, the model will be biased toward it. Keep the data roughly equal across classes.</p>
<p><strong>Variety:</strong> Fifty photos from different angles, distances, and lighting conditions will easily outperform two hundred near-identical shots from the same spot. The model needs to understand the concept of a "thumbs up", not memorise one specific photo of your specific thumb.</p>
</li>
</ul>
<p>Keep in mind that the actual machine learning model is usually just a tiny fraction of your overall codebase. The vast majority of what you write is going to be standard JavaScript. At the end of the day, it's just another asset in your stack.</p>
<h3 id="heading-step-2-setting-up-and-writing-the-code">Step 2: Setting up and Writing the Code</h3>
<p>Now that you have your model files, set up your project structure like this and create an <code>index.html</code> file:</p>
<pre><code class="language-plaintext">your-project/
├── index.html
├── model.json
├── weights.bin
└── metadata.json
</code></pre>
<p>The <code>model.json</code>, <code>weights.bin</code>, and <code>metadata.json</code> files all go in the same folder as your <code>index.html</code>. The demo loads them from the same directory using <code>const URL = "./"</code>.</p>
<p>To run it locally, open the folder in VS Code or your preferred IDE and use the <strong>Live Server</strong> extension. Just right-click <code>index.html</code> and select <strong>Open with Live Server</strong>. Opening the file directly in the browser without a server will cause CORS errors when loading the model files.</p>
<h3 id="heading-step-3-load-the-model-and-run-predictions">Step 3: Load the Model and Run Predictions</h3>
<p>Paste the following in your <code>index.html</code> file. This demo loads your Teachable Machine model, starts your webcam, and runs continuous predictions in a loop:</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;Teachable Machine - Webcam + Backend Switch Demo&lt;/title&gt;
    &lt;style&gt;
        body {
            font-family: Arial;
            text-align: center;
            margin: 20px;
        }

        #webcam-container {
            margin-top: 20px;
        }

        #label-container {
            margin-top: 10px;
            font-size: 18px;
            font-weight: bold;
        }

        button.backend-btn {
            margin: 5px;
            padding: 8px 16px;
            font-size: 16px;
            cursor: pointer;
        }

        #status {
            margin-top: 10px;
            font-weight: bold;
            color: #0078ff;
        }

        table {
            margin: 20px auto;
            border-collapse: collapse;
            width: 80%;
            max-width: 600px;
        }

        th,
        td {
            border: 1px solid #ccc;
            padding: 10px;
        }

        th {
            background: #0078ff;
            color: white;
        }
    &lt;/style&gt;
&lt;/head&gt;

&lt;body&gt;
    &lt;h2&gt;AI in the web Demo&lt;/h2&gt;

    &lt;div&gt;
        &lt;button class="backend-btn" onclick="switchBackend('cpu')"&gt;CPU&lt;/button&gt;
        &lt;button class="backend-btn" onclick="switchBackend('webgl')"&gt;WebGL&lt;/button&gt;
        &lt;button class="backend-btn" onclick="switchBackend('webgpu')"&gt;WebGPU&lt;/button&gt;
    &lt;/div&gt;

    &lt;p id="status"&gt;Click a backend to start&lt;/p&gt;

    &lt;table&gt;
        &lt;thead&gt;
            &lt;tr&gt;
                &lt;th&gt;Backend&lt;/th&gt;
                &lt;th&gt;Load Time (s)&lt;/th&gt;
                &lt;th&gt;Inference Time (ms)&lt;/th&gt;
                &lt;th&gt;Status&lt;/th&gt;
            &lt;/tr&gt;
        &lt;/thead&gt;
        &lt;tbody id="results"&gt;&lt;/tbody&gt;
    &lt;/table&gt;

    &lt;div id="webcam-container"&gt;&lt;/div&gt;
    &lt;div id="label-container"&gt;&lt;/div&gt;

    &lt;script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@latest/dist/tf.min.js"&gt;&lt;/script&gt;
    &lt;script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgpu"&gt;&lt;/script&gt;
    &lt;script
        src="https://cdn.jsdelivr.net/npm/@teachablemachine/image@latest/dist/teachablemachine-image.min.js"&gt;&lt;/script&gt;

    &lt;script&gt;
        const URL = "./";
        const resultsTable = document.getElementById("results");
        const statusEl = document.getElementById("status");
        const backends = ["cpu", "webgl", "webgpu"];

        let model, webcam, maxPredictions;
        const backendResults = {};

        // Initialize webcam
        async function initWebcam() {
            if (!webcam) {
                webcam = new tmImage.Webcam(200, 200, true);
                await webcam.setup();
                await webcam.play();
                document.getElementById("webcam-container").appendChild(webcam.canvas);

                const labelContainer = document.getElementById("label-container");
                labelContainer.innerHTML = "";
                for (let i = 0; i &lt; 2; i++) labelContainer.appendChild(document.createElement("div"));
            }
        }

        async function switchBackend(backend) {
            statusEl.innerText = `Switching to ${backend.toUpperCase()}...`;

            await initWebcam();

            try {
                const startLoad = performance.now();
                await tf.setBackend(backend);
                await tf.ready();
                model = await tmImage.load(URL + "model.json", URL + "metadata.json");
                maxPredictions = model.getTotalClasses();
                const endLoad = performance.now();
                const loadTime = ((endLoad - startLoad) / 1000).toFixed(2);

                // Single inference to measure time
                const startInference = performance.now();
                await model.predict(webcam.canvas);
                const endInference = performance.now();
                const inferenceTime = (endInference - startInference).toFixed(1);

                // Store results
                backendResults[backend] = { loadTime, inferenceTime };

                updateTable();

                statusEl.innerText = `${backend.toUpperCase()} ready`;
            } catch (err) {
                console.error(`${backend} not supported:`, err);
                statusEl.innerText = `${backend.toUpperCase()} not supported`;
            }
        }


        function updateTable() {
            resultsTable.innerHTML = "";
            for (let backend of backends) {
                const row = document.createElement("tr");
                const backendCell = document.createElement("td");
                const loadCell = document.createElement("td");
                const inferenceCell = document.createElement("td");
                const statusCell = document.createElement("td");

                backendCell.textContent = backend.toUpperCase();

                if (backendResults[backend]) {
                    loadCell.textContent = backendResults[backend].loadTime;
                    inferenceCell.textContent = backendResults[backend].inferenceTime;
                    statusCell.textContent = "✓";
                } else {
                    loadCell.textContent = "-";
                    inferenceCell.textContent = "-";
                    statusCell.textContent = "-";
                }

                row.appendChild(backendCell);
                row.appendChild(loadCell);
                row.appendChild(inferenceCell);
                row.appendChild(statusCell);
                resultsTable.appendChild(row);
            }
        }

        // Continuous prediction loop
        async function loop() {
            if (webcam &amp;&amp; model) {
                webcam.update();
                const prediction = await model.predict(webcam.canvas);
                const labelContainer = document.getElementById("label-container");
                labelContainer.innerHTML = "";
                for (let i = 0; i &lt; maxPredictions; i++) {
                    const p = document.createElement("div");
                    p.textContent = `\({prediction[i].className}: \){(prediction[i].probability * 100).toFixed(1)}%`;
                    labelContainer.appendChild(p);
                }
            }
            requestAnimationFrame(loop);
        }

        loop();
    &lt;/script&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre>
<p>A few things worth understanding about what this code is doing:</p>
<p>The <code>switchBackend</code> function does more than just swap the backend. Each time you click a backend button, it records how long the model takes to load on that backend and how long a single inference takes. Those numbers go straight into the comparison table so you can see the difference without having to look at console logs.</p>
<p>The <code>loop</code> function runs continuously using <code>requestAnimationFrame</code>. Every frame, it grabs the current webcam image, passes it to the model, and updates the prediction labels on screen. This is what makes the detection feel real-time.</p>
<p>Notice that <code>initWebcam</code> only runs once. It checks if <code>webcam</code> already exists before setting up. Switching backends reloads the model but keeps the same webcam stream running.</p>
<p>Open Chrome DevTools and go to the <strong>Network tab</strong> while the demo runs. After the model files finish loading, you'll see zero outbound requests. Every prediction is happening entirely in the browser.</p>
<h3 id="heading-step-4-switch-backends-and-compare-performance">Step 4: Switch Backends and Compare Performance</h3>
<p>Once the demo is running, click each backend button one at a time: CPU, then WebGL, then WebGPU. The table updates after each switch and shows you the load time in seconds and inference time in milliseconds for each backend side by side.</p>
<p>Here's what you should expect to see:</p>
<ul>
<li><p><strong>CPU</strong> will be the slowest with everything running in plain JavaScript</p>
</li>
<li><p><strong>WebGL</strong> will be noticeably faster as the GPU is now handling the tensor operations</p>
</li>
<li><p><strong>WebGPU</strong> will be the fastest with true GPU compute and less overhead than WebGL. The exact numbers depend on your machine, but the gap between CPU and WebGPU is usually significant enough to see immediately in the table.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/66058baaeb0049c5f549a186/6332c651-9b96-45c7-95cc-0d842595ff51.png" alt="Demo with network tab" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><strong>Note:</strong> WebGPU requires Chrome with the flag enabled. If the WebGPU button shows "not supported", go to <code>chrome://flags/#enable-unsafe-webgpu</code>, enable it, and restart Chrome.</p>
<h2 id="heading-chromes-built-in-ai-apis">Chrome's Built-in AI APIs</h2>
<p>Beyond loading your own models, Chrome is rolling out native AI capabilities that you can hook into directly through browser APIs. This means no managing bulky model files, no importing TensorFlow.js, and zero manual setup.</p>
<p>The powerhouse here is Gemini Nano, a lightweight version of Google's Gemini model built to run completely on-device inside Chrome. It handles tasks like smart replies and page summarization right in the browser without ever making a cloud call.</p>
<p>If you want to build with it, you can tap into these experimental APIs that Chrome exposes to developers:</p>
<pre><code class="language-plaintext">chrome://flags → search "Prompt API for Gemini Nano" → Enable → Restart Chrome
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/66058baaeb0049c5f549a186/c1db08aa-b5b1-4496-9553-536bbc68a442.png" alt="Gemini nano" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>These are still experimental and behind flags. But they show clearly where the platform is heading.</p>
<p>For the full prerequisites and setup guide for Chrome's built-in AI, see the <a href="https://developer.chrome.com/docs/ai/get-started">official Chrome AI getting started documentation</a>.</p>
<h2 id="heading-where-web-ai-is-headed">Where Web AI Is Headed</h2>
<p>The browser is evolving into something that doesn't really have a clean name yet. It's no longer just a document viewer, and it's not quite a native app runtime either. Instead, it's becoming an intelligent edge node – a piece of infrastructure that can perceive, process, and act all on its own, without constantly phoning home for permission.</p>
<p>A few massive shifts are already well underway:</p>
<ul>
<li><p><strong>Native AI built directly into the platform:</strong> AI capabilities are turning into standard browser APIs. Because they're cached and shared across the entire ecosystem, you won't have to re-download massive models for every single domain you visit.  </p>
<p>Browsers designed with AI as their core foundation are already popping up. OpenAI's Atlas browser is a perfect early signal of this trend. Every year, the idea of the browser acting as an intelligent agent platform rather than a simple content renderer gets more concrete.</p>
</li>
<li><p><strong>The developer shift:</strong> For developers, the immediate future is clear: a significant chunk of AI features that currently live on expensive servers will migrate straight to the client side. It won't be everything, but the lightweight, high-frequency, and privacy-sensitive tasks will absolutely make the jump.</p>
</li>
</ul>
<p>WebGPU isn't just a flashy demo technology, and browser inference is definitely not a toy. These are serious production tools, and they're only getting more capable as AI models shrink and user hardware gets more powerful.</p>
<p>If you're currently building an interactive, AI-powered feature, it's well worth pausing to ask yourself: <em>does this actually need a server?</em></p>
<p>Sometimes the answer is still yes. But more and more often, the answer is a definitive no.</p>
<h2 id="heading-what-you-learned">What You Learned</h2>
<p>In this tutorial, we covered:</p>
<ul>
<li><p>What Web AI is and how it differs from cloud-based AI</p>
</li>
<li><p>When to use browser AI versus cloud AI and how a hybrid approach works</p>
</li>
<li><p>The technology stack behind browser AI: tensors, TensorFlow.js, WebAssembly, WebGL, WebGPU, and MediaPipe</p>
</li>
<li><p>How to train a custom model with Teachable Machine and export it for the browser</p>
</li>
<li><p>How to load that model, run it against live webcam input, and manage GPU memory correctly</p>
</li>
<li><p>How to benchmark WebGL vs WebGPU inference times to measure real performance differences</p>
</li>
<li><p>How to access Chrome's built-in AI APIs including Gemini Nano</p>
</li>
</ul>
<p>If you found this useful or want to connect, you can find me on <a href="https://twitter.com/timi471">Twitter/X</a> or <a href="https://www.linkedin.com/in/ayantunji-timilehin">LinkedIn</a>.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://www.tensorflow.org/js">TensorFlow.js Documentation</a></p>
</li>
<li><p><a href="https://teachablemachine.withgoogle.com">Teachable Machine</a></p>
</li>
<li><p><a href="https://mediapipe-studio.webapps.google.com/home">MediaPipe Studio</a></p>
</li>
<li><p><a href="https://developer.chrome.com/docs/web-platform/webgpu">WebGPU in Chrome</a></p>
</li>
<li><p><a href="https://developer.chrome.com/docs/ai/get-started">Chrome Built-in AI — Getting Started</a></p>
</li>
<li><p><a href="https://developer.chrome.com/docs/ai/translator-api">Chrome AI Translator API</a></p>
</li>
<li><p><a href="https://github.com/GoogleChromeLabs/web-ai-demos">Google Web AI Demos on GitHub</a></p>
</li>
<li><p><a href="https://huggingface.co/docs/transformers.js">Hugging Face Transformers.js</a></p>
</li>
<li><p><a href="https://webllm.mlc.ai">WebLLM — Run LLMs in the Browser</a></p>
</li>
<li><p><a href="https://blog.google/products/chrome/new-ai-features-for-chrome/">Chrome AI Features — Google Blog</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Apply Academic Theories to Human-Centered Web Design [Full Handbook] ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever abandoned an app right at the sign‑up page? Or felt uneasy navigating a website because the buttons were scattered randomly, the colors clashed, and the layout felt confusing and unneces ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-apply-academic-theories-to-human-centered-web-design-handbook/</link>
                <guid isPermaLink="false">69fe29e9f239332df4f7cd02</guid>
                
                    <category>
                        <![CDATA[ Frontend Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ UX ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ux design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ UI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ UI Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ user experience ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ MathJax ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Great John ]]>
                </dc:creator>
                <pubDate>Fri, 08 May 2026 18:22:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d7621fda-83a6-460e-aa38-bce970d4a655.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever abandoned an app right at the sign‑up page? Or felt uneasy navigating a website because the buttons were scattered randomly, the colors clashed, and the layout felt confusing and unnecessarily complex?</p>
<p>Maybe you were asked to complete twenty fields in one go. You carefully filled everything out, hit Submit — and only then were you told that your password didn't meet some hidden, unspoken requirement. A requirement that was never communicated upfront.</p>
<p>Instead of helpful guidance, you were met with a vague message: “Invalid input." Invalid how, you wonder?</p>
<p>Required fields weren’t marked. There was no real‑time validation. No helpful red outline showing which field was wrong. Just a generic prompt telling you to “go back and correct missing information,” as if you’re supposed to magically know what the system wants.</p>
<p>So you scroll.</p>
<p>You search.</p>
<p>You guess.</p>
<p>And you're now getting frustrated.</p>
<p>The reason you're frustrated is simple: no one enjoys repeating a task they thought they had already completed — especially when the mistakes could've been prevented with clear guidance along the way.</p>
<p>You manage to fill in the form and you tap the Submit button.</p>
<p>Nothing happens.</p>
<p>No loading spinner.</p>
<p>No subtle animation.</p>
<p>No confirmation message.</p>
<p>No success screen.</p>
<p>Just silence. For a brief moment, you’re left wondering: Did it go through? So you tap again. And maybe… one more time.</p>
<p>At this point, you become fed up and you either postpone the signup process to when you have the time, or you may not ever return.</p>
<p>Even if you haven’t experienced this exact scenario, you’ve almost certainly felt the same kind of friction: that moment when a digital interface makes you pause, hesitate, or wonder what you’re supposed to do next.</p>
<p>These frustrations often arise because frontend developers either overlook or are unaware of the essential design principles and theories that underpin a smooth, intuitive user experience.</p>
<p>As a frontend developer, your interface should minimise cognitive load, provide immediate clarity, and guide users effortlessly through every task.</p>
<p>In this handbook, I'll introduce the academic theories that should inform and elevate your frontend decisions.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-10-fittss-law">1.0 Fitts’s Law:</a></p>
<ul>
<li><p><a href="#heading-11-use-padding-wisely">1.1 Use padding wisely</a></p>
</li>
<li><p><a href="#heading-12-use-infinite-targets">1.2 Use infinite targets</a></p>
</li>
<li><p><a href="#heading-design-takeaway-from-fitts-law">Design Takeaway from Fitts Law:</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-20-hicks-law">2.0 Hick's Law:</a></p>
<ul>
<li><a href="#heading-design-takeaway-from-hicks-law">Design Takeaway from Hick's Law</a></li>
</ul>
</li>
<li><p><a href="#heading-30-gestalt-principles">3.0 Gestalt Principles:</a></p>
<ul>
<li><p><a href="#heading-key-gestalt-principles">Key Gestalt Principles:</a></p>
</li>
<li><p><a href="#heading-31-proximity">3.1 Proximity</a></p>
</li>
<li><p><a href="#heading-32-similarity">3.2 Similarity</a></p>
</li>
<li><p><a href="#heading-33-continuity">3.3 Continuity</a></p>
</li>
<li><p><a href="#heading-34-closure">3.4 Closure</a></p>
</li>
<li><p><a href="#heading-35-figureground">3.5 Figure/Ground</a></p>
</li>
<li><p><a href="#heading-36-common-fate">3.6 Common Fate</a></p>
</li>
<li><p><a href="#heading-37-focal-point">3.7 Focal Point</a></p>
</li>
<li><p><a href="#heading-design-takeaways-from-the-gestalt-principles">Design Takeaways from the Gestalt Principles</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-40-von-restorff-effect-the-isolation-effect">4.0 Von Restorff Effect (The Isolation Effect):</a></p>
<ul>
<li><a href="#heading-design-takeway-from-von-restorff">Design takeway from Von Restorff</a></li>
</ul>
</li>
<li><p><a href="#heading-50-jakobs-law">5.0 Jakob’s Law</a></p>
<ul>
<li><a href="#heading-design-takeaway-from-jakobs-law">Design Takeaway from Jakob's Law</a></li>
</ul>
</li>
<li><p><a href="#heading-60-millers-law">6.0 Miller’s Law</a></p>
<ul>
<li><a href="#heading-design-takeaway-from-millers-law">Design Takeaway from Miller's Law</a></li>
</ul>
</li>
<li><p><a href="#heading-70-the-goal-gradient-hypothesis">7.0 The Goal-Gradient Hypothesis</a></p>
<ul>
<li><a href="#heading-design-takeaway-from-goal-gradient-hypothesis">Design Takeaway from Goal-Gradient Hypothesis</a></li>
</ul>
</li>
<li><p><a href="#heading-80-zeigarnik-effect">8.0 Zeigarnik Effect</a></p>
<ul>
<li><a href="#heading-design-takeaway-from-zeigarnik-effect">Design Takeaway from Zeigarnik Effect</a></li>
</ul>
</li>
<li><p><a href="#heading-90-teslas-law">9.0 Tesla’s Law:</a></p>
<ul>
<li><a href="#heading-design-takeaway-from-teslas-law">Design Takeaway from Tesla's Law</a></li>
</ul>
</li>
<li><p><a href="#heading-100-peak-end-rule">10.0 Peak End Rule:</a></p>
<ul>
<li><a href="#heading-design-takeaway-from-peak-end-rule">Design takeaway from Peak End Rule</a></li>
</ul>
</li>
<li><p><a href="#heading-110-postels-law">11.0 Postel’s Law:</a></p>
<ul>
<li><a href="#heading-design-takeaway-from-postels-law">Design Takeaway from Postel's Law</a></li>
</ul>
</li>
<li><p><a href="#heading-120-doherty-threshold">12.0 Doherty Threshold:</a></p>
<ul>
<li><a href="#heading-design-takeaways-from-doherty-threshold">Design Takeaways from Doherty Threshold</a></li>
</ul>
</li>
<li><p><a href="#heading-130-serial-position-effect-primacy-and-recency">13.0 Serial Position Effect (Primacy and Recency):</a></p>
<ul>
<li><a href="#heading-design-takeaways-serial-position-effect">Design Takeaways Serial Position Effect</a></li>
</ul>
</li>
<li><p><a href="#heading-140-occams-razor">14.0 Occam’s Razor:</a></p>
<ul>
<li><a href="#heading-design-takeaway-from-occams-razor">Design Takeaway from Occam's Razor</a></li>
</ul>
</li>
<li><p><a href="#heading-150-parkinsons-law">15.0 Parkinson's Law</a></p>
<ul>
<li><a href="#heading-design-takeaway-for-parkinsons-law">Design Takeaway for Parkinson's law</a></li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<p>You might wonder what academic theories have to do with frontend development.</p>
<p>The answer is simple. Academic theories aren't abstract ideas. There are the result of rigorous scientific investigation — controlled experiments, validated models, and decades of research into how humans think, learn, perceive, and interact with information.</p>
<p>Because these theories are grounded in evidence rather than opinion, they offer reliable guidance for building interfaces that align with how the human brain actually processes information.</p>
<p>Applying them to frontend development means you're not designing by guesswork or personal preference. Instead, you're applying tested, scientific insights to create clearer, faster, more humane user experiences.</p>
<p>In other words, when you build with academic theory in mind, your frontend becomes more than just visually appealing — it becomes cognitively efficient, behaviourally aligned, and measurably easier for users to navigate.</p>
<p>You can use the following laws and principles to guide your development work. Let’s start by looking at Fitt’s law.</p>
<h2 id="heading-10-fittss-law"><strong>1.0 Fitts’s Law:</strong></h2>
<p>Fitts’s law is the brainchild of Paul Fitts. He was among the early psychologists who recognised that many human errors result from flawed design rather than simple human weakness.</p>
<p>During World War II, he studied airplane cockpit layouts and concluded that numerous incidents attributed to pilot error were actually caused by poor design decisions (Hall, 2023; Budiu, 2022).</p>
<p>Here's the formula:</p>
<p>$$T = a + b \cdot \log_2\left(1 + \frac{D}{W}\right)$$</p>
<p>T = Movement Time</p>
<p>D = Distance to the target</p>
<p>W = Width (size) of the target</p>
<p>a, b = Empirically determined constants</p>
<p>Based on his findings, Fitts postulated that the time required to acquire/reach a target is determined by the distance to the target and the size of the target.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/7d2699bd-4878-4ab0-8669-21616cb8faf1.png" alt="7d2699bd-4878-4ab0-8669-21616cb8faf1" style="display:block;margin:0 auto" width="691" height="244" loading="lazy">

<p><em>Fig 1.0: Illustration of Fitts Law.</em></p>
<p>From the above, between Target B and Target C, it will be faster to interact with Target C than Target B simply because of the distance (Target B is farther away). Interestingly, though Target A and Target C are at the same distance, Target C will still be faster to interact with and less error-prone because of its larger size.</p>
<p>In simple terms, Fitt’s Law tells us that the time required to move to a target depends on two main factors: the distance to the target and the size of the target. The farther away an element is, the longer it takes to reach. The smaller it is, the more precision it demands, which increases the interaction time and the likelihood of errors.</p>
<p>Conversely, closer and larger targets reduce cognitive load, motor effort, and frustration.</p>
<p>In a nutshell, Fitts’s main message to developers is to reduce the distance users must travel on the screen and to make important buttons large and visually dominant.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/bc293949-cadc-46b2-892d-bdfa1dfc6db3.jpg" alt="bc293949-cadc-46b2-892d-bdfa1dfc6db3" style="display:block;margin:0 auto" width="928" height="664" loading="lazy">

<p><em>Fig 1.1: Showing Call-to-Action buttons are the largest and most visually prominent elements on each screen.</em></p>
<p>From the image above, you can see that the Call-to-Action buttons on each of the screens are the most visually dominant button and largest in size. They're also placed within the natural region. This makes them faster/easier to interact with.</p>
<p>You should also place your Call-to-Action button within the natural zone. This is a zone on a mobile phone where it's easy to reach with the thumb (as most people use their thumbs to select things on a phone screen). Here's a diagram showing the "natural zone" on a typical smartphone. It's much faster for a user to interact within the "natural zone" than the "hard zone" (see figure).</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e98d8d91-b4e6-4b4a-96e5-2524a6e09028.png" alt="e98d8d91-b4e6-4b4a-96e5-2524a6e09028" style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p><em>Fig 1.2: Showing three different zones for buttons placement (natural, stretching and hard region)</em></p>
<h3 id="heading-11-use-padding-wisely">1.1 Use Padding Wisely</h3>
<p>Fitts' law can be applied to your development by increasing padding wisely. You can also use padding to increase the interactive area. By doing this, you're increasing the size of the targets.</p>
<p>This is important, because imagine a menu that disappears the moment your cursor drifts a few inches away. You’re weren't trying to close it — you simply moved slightly, and suddenly the entire menu collapses. That tiny slip forces you to start the interaction all over again. It’s a small mistake, but it creates a disproportionately frustrating experience.</p>
<p>This happens because the interactive area is too narrow.</p>
<p>That’s why effective padding — or more broadly, generous interactive zones — is essential. By increasing the clickable or hoverable area around a menu, you are increasing the size of the targets, which makes the interaction more stable, more forgiving, and far less cognitively demanding.</p>
<p>This ensures users can move naturally without fear of accidentally “falling off” the target.</p>
<h3 id="heading-12-use-infinite-targets">1.2 Use Infinite Targets</h3>
<p>Another fundamental principle that emerges from Fitt’s Law is the idea of infinite targets. When an interface element is placed at the very edge or corner of a screen, it becomes effectively “infinite” because the cursor can't move beyond the screen boundary. The edge acts as a physical barrier, allowing the user to fling the mouse in that direction without precision or careful aiming.</p>
<p>As a result, corners and edges become the fastest, easiest, and most reliable places for users to access important controls.</p>
<p>This is why operating systems such as Apple’s macOS and Microsoft Windows position their most essential menus and buttons at these locations. The macOS Apple Menu sits in the top‑left corner, Windows historically placed the Start button in the bottom‑left corner, and both systems anchor taskbars, docks, and notification areas along screen edges.</p>
<p>These placements reduce cognitive load, minimise motor effort, and increase interaction speed because users do not need to slow down or correct their cursor movement. The screen itself “catches” the pointer.</p>
<p>In essence, infinite targets transform small interface elements into large, easy‑to‑hit zones simply by leveraging the geometry of the screen.</p>
<p>What this means for you: place your most important and frequently used actions where users can reach them with the least effort. Screen edges and corners act as natural stopping points, meaning users can't overshoot them.</p>
<h3 id="heading-design-takeaways-from-fitts-law">Design Takeaways from Fitts Law:</h3>
<p><strong>Place Primary Actions Where the Task Ends:</strong><br>Placing a submit button at the top‑right forces users to travel all the way back after completing a long form. This increases interaction cost and breaks flow. The best place for a submit button is at the bottom of the form — exactly where the user finishes the task. This aligns with natural reading and interaction patterns.</p>
<p><strong>Keep Related Actions Physically Close:</strong><br>Separating “Add to Cart” and “Check Out” across opposite sides of the screen forces unnecessary thumb movement. Group related actions to reduce effort and speed up decisions.</p>
<p><strong>Make Primary Targets Large and Visually Dominant:</strong><br>Your main CTAs (“Subscribe Now,” “Pay Now,” “Create Account,” “Sign Up”) should be the most recognisable elements on the screen. Large, high‑contrast targets reduce errors and improve speed.</p>
<p><strong>Place High‑Value Actions at Screen Edges and Corners:</strong><br>Edges and corners act as “infinite targets” because the cursor can’t overshoot them. This makes them the fastest, easiest, and most reliable places for critical controls.</p>
<p>A tiny icon in the middle of the screen is hard to hit. The same icon placed at an edge becomes effectively huge because the boundary “catches” the pointer. Also, actions like navigation, primary CTAs, or global controls should live where users can reach them with minimal effort. Avoid burying important actions in the centre of the screen.</p>
<p><strong>Increase Target Size With Generous Padding:</strong><br>Small interactive zones force users to aim with pixel‑level precision. Adding padding expands the clickable or hoverable area, making interactions easier, faster, and more forgiving.</p>
<p><strong>Prevent Accidental “Fall‑Off” With Larger Hit Areas:</strong><br>Menus that collapse the moment the cursor drifts slightly create frustration. A wider interactive zone keeps the menu open during natural mouse movement, reducing accidental resets.</p>
<p>Users don’t move perfectly. Interfaces should accommodate slight slips without punishing them. Larger targets reduce cognitive load and eliminate unnecessary frustration. so by increasing the effective size of buttons, menus, and controls, you create interactions that feel stable and predictable, and users can move confidently without fear of losing their place.</p>
<p><strong>To Sum Up:</strong> The farther away an element is, the longer it takes to reach. The smaller it is, the more precision it demands, which increases the interaction time and the likelihood of errors. Conversely, closer and larger targets reduce cognitive load, motor effort, and frustration.</p>
<h2 id="heading-20-hicks-law"><strong>2.0 Hick's Law</strong>:</h2>
<p>Hick’s Law is a psychological principle that describes the relationship between the number of choices presented to a user and the time it takes them to make a decision. It was formulated by William Edmund Hick in 1952 (Yablonski, 2022; Proctor &amp; Scheider, 2018).</p>
<p>The law states that as the number of options increases, the decision time increases logarithmically. In simple terms, more choices slow users down, while fewer choices speed up decision-making.</p>
<p>$$T = a + b \cdot \log_2(n + 1)$$</p>
<p>Where:</p>
<p>T = time to make a decision,</p>
<p>n = number of choices,</p>
<p>b= a constant that depends on the task and the individual</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/d2c45f4b-77e9-42dc-a9c3-165dfe5f7ce7.jpg" alt="d2c45f4b-77e9-42dc-a9c3-165dfe5f7ce7" style="display:block;margin:0 auto" width="1136" height="692" loading="lazy">

<p><em>Figure 2.0 illustrates the relationship between user experience, reaction time, and the number of actions.</em></p>
<p>This is how users feel, for example, when they encounter a form that asks for too much information upfront. The longer the form gets, the more frustrated they become.</p>
<p>Examples of this are overloading menus with too many items, presenting long, unorganised forms, giving too many calls-to-action on one screen, and building nested menus with excessive depth.</p>
<p>All of these create friction and can lead to cognitive overload.</p>
<h3 id="heading-design-takeaway-from-hicks-law">Design Takeaway from Hick's Law</h3>
<p><strong>Avoid Overloading Users With Too Many Actions:</strong><br>Too many buttons, menu items, or choices at once increases cognitive load and slows decision‑making. Users freeze when everything competes for attention.</p>
<p><strong>Keep Navigation Clean and Focused:</strong><br>Cluttered menus hurt both usability and SEO. Search engines struggle to track overly complex navigation structures, and users struggle to find what matters.</p>
<p><strong>Use Progressive Disclosure to Reduce Complexity:</strong><br>Hide advanced or rarely used options under “More” or expandable sections. Reveal complexity only when the user needs it.</p>
<p><strong>Break Complex Tasks Into Smaller, Manageable Steps:</strong><br>Progressive disclosure works beautifully for multi‑step forms and decision flows. Smaller steps reduce overwhelm and improve completion rates.</p>
<p><strong>Group Related Options Into Logical Categories:</strong><br>Organising actions into meaningful clusters helps users process information faster. For example, placing “Edit” and “Delete” together leverages natural mental grouping.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/mbYIfRxSkHs" 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><em>Video 2.0: Video description of Progressive Disclosure.</em></p>
<p>From the video above, instead of showing all the menu details at once, it is better to hide them initially. As you can see, the additional information only appears when the arrow down button is pressed. This approach prevents overwhelming the user and keeps the interface clean and focused.</p>
<p>You should also reduce decision anxiety, as too many choices create doubt and friction (as they say, the more you ask from a user, the less you get).</p>
<p>Beyond this, try to use recommended labels, show brief descriptions, provide visual previews, and use comparison tables wisely to show comparison between products especially when they have many characteristics. An example of a comparison table is shown below:</p>
<p><a href="https://drive.google.com/file/d/1sY-tb9W1QDnyrH9dd3NSYsteP9WoMT27/view?usp=drive_link"><img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/5e52527a-3faf-4da1-97c2-a2a09ca7af8b.jpg" alt="Use of comparison table to compare products" style="display:block;margin:0 auto" width="1038" height="972" loading="lazy"></a></p>
<p><em>Figure 2.1: A comparison table being used to simplify complex information.</em></p>
<p>Also, rather than showing advanced configuration options by default, display only the most commonly used settings. Advanced options can be hidden under an expandable section like “Advanced” or “More Settings. This makes your interface less cluttered and more visually organized.</p>
<p>And speaking of visual organization, this is the perfect moment to introduce Gestalt principles — the psychological rules that explain how users naturally group and interpret what they see.</p>
<p><strong>To Sum Up</strong>: As the number of options increases, the decision time increases logarithmically.</p>
<h2 id="heading-30-gestalt-principles"><strong>3.0 Gestalt Principles</strong>:</h2>
<p>In the 1920s, a group of German psychologists – Max Wertheimer, Kurt Koffka, and Wolfgang Köhlern – introduced what are now known as the Gestalt Principles. Their work sought to understand how humans perceive and interpret visual information (Bustamante, 2023).</p>
<p>The word “Gestalt” is German for “unified whole,” reflecting the core idea behind the theory: people naturally perceive objects as organised patterns and complete forms rather than as separate, disconnected parts.</p>
<p>These principles explain how the human mind structures visual elements to make sense of the world. Over time, they have become highly influential in fields such as design, user experience (UX), psychology, and data visualization, where understanding perception is critical.</p>
<h3 id="heading-key-gestalt-principles">Key Gestalt Principles:</h3>
<h3 id="heading-31-proximity">3.1 Proximity</h3>
<p>Elements that are placed close to each other are perceived as a group, while those spaced far apart are seen as separate. This is why labels are placed directly next to their corresponding input fields.</p>
<p>For example: In a blog feed, the "Title," "Author," and "Date" should have small margins between them (8px), while the space between one blog post card and the next should be much larger (40px). This tells the user's brain: "These three text strings belong to this specific post."</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/b68ab7f4-1b2e-47f1-8e8c-8ae8f32198c1.jpg" alt="b68ab7f4-1b2e-47f1-8e8c-8ae8f32198c1" style="display:block;margin:0 auto" width="1046" height="956" loading="lazy">

<p><em>Fig 3:0 Illustration of proximity (Gestalt Principle)</em></p>
<p>From the fig above, the spacing within the blog feed plays a powerful role in how effortlessly users interpret what they see. When elements sit close together, the brain instinctively treats them as belonging to the same unit. This is why placing the author credit just 8px beneath the title creates an immediate mental link. The viewer doesn’t need to pause or decode who wrote which article; proximity does the cognitive work automatically, forming a tight, intuitive grouping.</p>
<p>Equally important is the generous 40px gap between individual cards. This larger spacing introduces “visual breathing room.” Without it, a feed can quickly collapse into a dense wall of text, overwhelming the user and discouraging exploration. The wider margin establishes a clear boundary—a natural stop-and-start rhythm—that makes each card feel distinct and the entire layout more scannable.</p>
<p>Finally, subtle spacing differences can guide behaviour, not just perception. The slightly larger 12px margin above the read‑more link separates it from the passive information above it. This spacing cues the user that the link represents an action rather than another piece of descriptive text. It’s a small adjustment, but it shifts the element’s role from informational to interactive, helping users understand what they can <em>do</em> next.</p>
<p>Together, these spacing decisions transform a simple list of posts into a structured, intuitive, and behaviourally clear interface—one where the user never has to think about the layout, because the layout is already thinking for them.</p>
<p>Proximity controls meaning: move elements closer to show connection, separate them to show difference.</p>
<h3 id="heading-32-similarity">3.2 Similarity</h3>
<p>We naturally group elements that share similar visual characteristics, such as color, shape, size, or orientation.</p>
<p>For example, even if buttons are spread across a page, if they're all the same shade of blue, the user understands they perform similar functions.</p>
<p>If your primary "Submit" button is blue with rounded corners, every other primary action on your site should look exactly the same. If you suddenly use a square red button for a primary action, the user will be confused because the "similarity" is broken.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/151d9658-5a4b-4d1d-b885-02c73c53cdbd.jpg" alt="151d9658-5a4b-4d1d-b885-02c73c53cdbd" style="display:block;margin:0 auto" width="1618" height="2170" loading="lazy">

<p><em>Fig 3:1 : illustration of similarity (Gestalt principle)</em></p>
<p>As you can see from above, the layout clearly demonstrates how the Gestalt Principle of Similarity works by showing two different visual situations: one where everything matches, and one where a single element breaks the pattern.</p>
<p>All three product cards share the same visual characteristics:</p>
<ul>
<li><p>Same card shape</p>
</li>
<li><p>Same border and shadow</p>
</li>
<li><p>Same image size and placement</p>
</li>
<li><p>Same blue “Add to Cart” button</p>
</li>
<li><p>Same font style and spacing</p>
</li>
</ul>
<p>Because these elements look alike, your brain automatically groups them as one category — “products that belong together.”<br>You don’t have to think about it; the similarity creates instant visual unity.</p>
<p>This is the Gestalt Principle of Similarity in action.</p>
<p>In the second row, everything is still similar except one button:</p>
<ul>
<li><p>The middle product’s button is orange, not blue</p>
</li>
<li><p>It has square corners, not rounded</p>
</li>
<li><p>The text is italic, not regular</p>
</li>
<li><p>The label changes to “Quick Buy”</p>
</li>
</ul>
<p>Because this button breaks the shared pattern, your brain immediately notices it and treats it as different or special.</p>
<p>Developers can use broken similarity to intentionally highlight featured items, promotions, or urgent actions.</p>
<p>When similarity is broken, the different element stands out and draws attention.</p>
<h3 id="heading-33-continuity">3.3 Continuity</h3>
<p>The human eye prefers to follow a continuous path or curve rather than jagged or broken lines. We perceive items aligned on a line or curve as being related. This is often used in navigation menus or horizontal carousels to guide the user's gaze.</p>
<p>For example, you might have a horizontal carousel where the last visible card is slightly "cut off" at the edge of the screen. This visual break creates a path that encourages the user to keep scrolling as their eyes follow the line of cards.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/e44556e8-4174-4073-9c05-63b5df9a1278.jpg" alt="e44556e8-4174-4073-9c05-63b5df9a1278" style="display:block;margin:0 auto" width="1650" height="214" loading="lazy">

<p><em>Fig 3:2: illustration of continuity (Gestalt principle)</em></p>
<p>As you can see, all four form fields — <em>First Name</em>, <em>Last Name</em>, <em>Email Address</em>, and <em>Phone Number</em> — are perfectly aligned along one continuous horizontal path. Because the human eye naturally prefers to follow an unbroken line, your gaze moves smoothly from left to right across the fields without effort.</p>
<p>The final field is slightly cut off at the edge, which creates a subtle visual cue that the line continues beyond the visible area. This encourages the user to keep scrolling or swiping, because their eyes are already following the direction of the form.<br>when elements are arranged along a straight path, curve, or flow, the brain automatically treats them as connected and expects the pattern to continue.</p>
<p>Another example is Instagram Stories, which are arranged in a smooth horizontal line at the top of the app. Instagram reinforces this by slightly revealing the next story circle at the edge of the screen. That tiny “peek” acts as a continuation cue — your eyes expect the line to keep going, so your finger follows.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/bb019a6d-33c9-4509-b1c7-191b5a453c46.jpg" alt="bb019a6d-33c9-4509-b1c7-191b5a453c46" style="display:block;margin:0 auto" width="1320" height="520" loading="lazy">

<p><em>Fig 3:3: illustration of continuity (Gestalt principle)</em></p>
<p>As you can see from above, all the circular story icons are arranged in a straight horizontal line, and your visual system instinctively follows that line from left to right without effort.</p>
<p>The slight visibility of the next story at the edge of the screen strengthens this effect, signaling that the sequence continues beyond what's currently shown. Also, because the icons share the same size, spacing, and shape, there are no visual interruptions, allowing your eyes to glide across them in one continuous motion.</p>
<p>This seamless flow is exactly what continuity describes: the tendency of the human eye to follow the direction of a line or pattern, assuming it continues even when part of it is out of view.</p>
<p>Continuity is the tendency of the human eye to follow the direction of a line or pattern, assuming it continues even when part of it is out of view.</p>
<h3 id="heading-34-closure">3.4 Closure</h3>
<p>Closure refers to the mind’s ability to perceive a complete, unified form even when parts of that form are missing. Rather than requiring every boundary, line, or shape to be explicitly drawn, the brain instinctively fills in the gaps. When used intentionally, closure allows interfaces to feel cleaner, more elegant, and more cognitively efficient.</p>
<p>When we look at a complex arrangement of visual elements, we tend to look for a single, recognisable pattern. If an image is missing parts, our brains fill in the gaps to "close" the shape.</p>
<p>One of the most celebrated examples of closure in visual identity design is the panda symbol used by the World Wide Fund for Nature (WWF). This logo demonstrates how strategic omission can produce a memorable, emotionally resonant, and universally recognisable mark.</p>
<p>At first glance, the panda illustration appears simple, composed of a few bold black shapes arranged against a white background.</p>
<p>Yet a closer look reveals that the panda is not fully drawn. There are no outlines defining the body, no complete contours around the head, and no explicit boundaries separating limbs from background. Instead, the designer uses a series of carefully placed shapes (ears, eye patches, nose, and partial limbs) to imply the rest of the animal. The viewer’s mind fills in the missing information, completing the silhouette effortlessly.</p>
<p>This is closure at its most effective: the brain constructs a whole from fragments, creating a sense of completeness without visual overload.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/0c40effd-e4d2-4669-af1d-56faac976b4a.jpg" alt="0c40effd-e4d2-4669-af1d-56faac976b4a" style="display:block;margin:0 auto" width="522" height="784" loading="lazy">

<p><em>Fig 3:4: illustration of closure (Gestalt principle)</em></p>
<p>For example, a "hamburger menu" (three lines) isn't a literal drawer, but our brains "close" the shape to understand it represents a menu.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/b67253cd-2a2c-4565-9818-f5586dad6d36.jpg" alt="b67253cd-2a2c-4565-9818-f5586dad6d36" style="display:block;margin:0 auto" width="1244" height="236" loading="lazy">

<p><em>Fig 3:5: illustration of closure (Gestalt principle)</em></p>
<p>An example of closure in practice can be seen in step indicators commonly used in checkout flows. These components often rely on partial shapes, implied boundaries, and incomplete outlines to guide the user through a sequence of actions.</p>
<p>For instance, upcoming steps may be represented by dashed circles. Although the circles aren't fully drawn, the viewer immediately recognises them as complete shapes. The brain resolves the missing segments, allowing the interface to communicate progression without heavy borders or fully rendered icons. This subtle use of closure reduces visual clutter while preserving clarity.</p>
<p>Closure refers to the mind’s ability to perceive a complete, unified form even when parts of that form are missing.</p>
<h3 id="heading-35-figureground">3.5 Figure/Ground</h3>
<p>This principle describes the mind's tendency to separate an object (the figure) from its surrounding area (the ground or background). In web design, using a "modal" or "pop-up" relies on this: by blurring the background, you force the user to see the pop-up as the focal figure.</p>
<p>When a user clicks "Login" on a modal/lightbox, the background site often dims (the "Ground") while the login box stays bright and centered (the "Figure"). This immediate depth change tells the user exactly where their attention belongs.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/UCdjymjASOU" 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><em>Video 3.5.0 Video description of Figure/Ground (Gestalt Principle)</em></p>
<p>From the video above, you can see that when the Quick View button is clicked, the selected figure stands out while the background darkens. This contrast guides the user’s attention and helps them focus on the figure. Developers can use this technique to direct users’ attention to what matters most or to what they want users to notice.</p>
<p>This principle describes the mind's tendency to separate an object (the figure) from its surrounding area (the ground or background).</p>
<h3 id="heading-36-common-fate">3.6 Common Fate</h3>
<p>Elements that move in the same direction are perceived as more related than elements that are stationary or move in different directions. Think of a dropdown menu: when all sub-items slide down together, they are clearly part of the same "unit."</p>
<p>For example, when you click a FAQ header and five sub-items slide down at the exact same speed and direction, the "Common Fate" tells the user that all those items belong to that specific category. If they flew in from different directions, the relationship would be lost.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/T10xmlne6E4" 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><em>Video 3.6.1 Video description of common fate (Gestalt Principle)</em></p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/5FncGLRy0bM" 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><em>Video 3.6.2 Video description of common fate (Gestalt Principle)</em></p>
<p>From the video shown above, the e‑commerce animation example demonstrates these principles clearly by using two distinct motion patterns: a group of regular products that move upward together, and a pair of special‑category items that enter dramatically from the left. Through these contrasting movements, the interface communicates category differences without relying on text labels or explicit instructions.</p>
<p>Therefore, developers can use this motion‑based differentiation as a design strategy to guide users’ perception—allowing the interface to signal hierarchy, category structure, and product importance purely through animated behaviour rather than through static visual labels.</p>
<p>Elements that move in the same direction are perceived as more related than elements that are stationary or move in different directions.</p>
<h3 id="heading-37-focal-point">3.7 Focal Point</h3>
<p>Whatever stands out visually will capture and hold the viewer’s attention first. This is essentially the principle of emphasis. A bright "Sign Up" button in a sea of gray text acts as the focal point, directing the user's primary action.</p>
<p>For example, an alert banner or a pricing table should stand out from its surroundings. Beyond this, in a three-tier pricing table (Basic, Pro, Enterprise), the "Pro" column is often slightly larger or a different color. This creates a focal point that draws the eye to the "recommended" option immediately.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/711c9ee9-b546-4041-bf86-30c80154bd47.jpg" alt="711c9ee9-b546-4041-bf86-30c80154bd47" style="display:block;margin:0 auto" width="1784" height="646" loading="lazy">

<p><em>Fig 3:7: illustration of closure (Gestalt principle)</em></p>
<p>In visual interface design, the Gestalt principle of Focal Point plays a crucial role in directing user attention toward the most important element on a screen.</p>
<p>A focal point is created when one element breaks the established pattern of surrounding elements, making it stand out immediately.</p>
<p>In e‑commerce interfaces, this principle is often applied to highlight primary actions such as purchasing, subscribing, or upgrading. The “Buy Now” button provides a clear and practical example of how focal points function within a layout.</p>
<p>From the example above, the first two buttons share the same visual characteristics: neutral colours, and regular weight text. This repetition establishes a visual pattern that the user quickly becomes familiar with.</p>
<p>But the “Buy Now” button intentionally disrupts this pattern. It uses a bright colour, which contrasts sharply with the muted tones of the other buttons. This colour difference alone is enough to draw the eye, as humans are naturally sensitive to changes in hue and saturation within a uniform environment.</p>
<p>The Focal Point may sound like it's similar to the principle of Similarity, but the two operate in completely opposite ways within perceptual psychology.</p>
<p>Similarity explains how the mind naturally groups elements that share visual characteristics –&nbsp;such as colour, shape, or size – into coherent units. Once this grouping is established, the interface gains structure and predictability.</p>
<p>Focal Point, on the other hand, works by intentionally breaking that structure. Instead of reinforcing uniformity, it introduces a deliberate contrast – through colour, scale, brightness, or motion –&nbsp;to draw the viewer’s attention to one specific element.</p>
<p>In other words, Similarity creates the background pattern, while Focal Point identifies the one element that must stand out against that pattern.</p>
<p>Whatever stands out visually will capture and hold the viewer’s attention first.</p>
<h3 id="heading-design-takeaways-from-the-gestalt-principles">Design Takeaways from the Gestalt Principles</h3>
<p><strong>Use Spacing as Your Primary Grouping Tool:</strong><br>Elements that belong together should sit closer to each other than to anything else. Spacing communicates structure faster than borders or boxes. Use tight internal spacing (6–12px) for related items and wide external spacing (24–48px) to separate groups.</p>
<p><strong>Build a Strict, Consistent Visual System — and Stick to It:</strong><br>Define clear rules for button types, text styles, icon sizes, and alignment patterns. Consistent left‑aligned text blocks, predictable carousel lines, and stable flow patterns reduce cognitive load and make interfaces feel trustworthy.</p>
<p><strong>Guide the Brain With Spacing, Alignment, Consistency, Contrast, and Motion:</strong><br>The human brain is always trying to group, follow, and prioritise what it sees. Your job is to guide that instinct through intentional layout decisions, not fight against it.</p>
<h2 id="heading-40-von-restorff-effect-the-isolation-effect"><strong>4.0 Von Restorff Effect (The Isolation Effect)</strong>:</h2>
<p>This is the brainchild of Hedwig von Restorff, posited in 1933. In principle it states: An item that stands out is more noticable and more likely to be remembered than other items (Hunt, 1995).</p>
<p>So unique or visually distinct elements grab attention and are more memorable – in other words, distinctiveness dictates memory. When a user interacts with an interface, their brain naturally seeks patterns to minimize cognitive effort.</p>
<p>While consistency is generally a virtue in design, a perfectly uniform layout can lead to "banner blindness" or habituation, where the user stops noticing details.</p>
<p>By strategically breaking a pattern through changes in color, size, shape, or spacing, the developer can "isolate" an element, triggering a biological response that flags the item as high-priority.</p>
<p>Note that although the Focal Point principle may initially seem similar to the Von Restorff Effect, they describe two different psychological processes.</p>
<p>Focal Point is a Gestalt visual principle that explains how one element becomes the centre of attention within a composition because it carries the strongest visual contrast –&nbsp;through size, colour, brightness, position, or motion. Its purpose is to guide the viewer’s eye toward the most important element in the layout.</p>
<p>The Von Restorff Effect comes from cognitive psychology, not Gestalt theory. It states that an item that is noticeably different from a group of similar items is not only more attention‑grabbing but also more memorable.</p>
<p>So Focal Point is about where the eye goes first, while the Von Restorff Effect is about what the brain remembers later.</p>
<h3 id="heading-design-takeaways-from-von-restorff">Design takeaways from Von Restorff</h3>
<p><strong>Use Isolation to Make CTAs Impossible to Miss:</strong><br>On a page filled with neutral text and standard links, a single high‑contrast button (like a bold “Primary Blue” or “Emergency Red”) instantly becomes the standout element. This leverages the Von Restorff Effect to pull the user’s eye toward the most important action.</p>
<p><strong>Create a Visual “Hitch” in the Scan Path:</strong><br>A distinct CTA interrupts the user’s natural left‑to‑right, top‑to‑bottom scanning rhythm. This makes actions like “Buy Now” or “Sign Up” the first thing they notice and the last thing they forget.</p>
<p><strong>Make Critical Actions Visually Distinct:</strong><br>Because users naturally notice the one element that breaks a pattern, your most important actions should use deliberate contrast — color, size, shape, weight, or motion. Isolate key information instead of letting it blend into surrounding UI noise.</p>
<p><strong>Avoid Over‑Differentiation — or Nothing Stands Out:</strong> If every button is loud, animated, or uniquely styled, the interface becomes chaotic. The Von Restorff Effect only works when there is a clear, stable pattern — and you break it once, intentionally.</p>
<p><strong>To Sum Up:</strong> An item that stands out is more noticable and more likely to be remembered than other items.</p>
<h2 id="heading-50-jakobs-law"><strong>5.0 Jakob’s Law</strong></h2>
<p>Jakob’s Law states that users spend most of their time on other sites, so they expect your interface to behave like the ones they already know.</p>
<p>Familiar patterns — hamburger menus, top navigation, search icons, and clickable top‑left logos — reduce cognitive load because users don’t have to interpret anything new.</p>
<p>But while Jakob’s Law is foundational to UX, I think it can also unintentionally suppress innovation.</p>
<p>When developers over‑prioritise familiarity, they fall into a standardisation trap: endlessly optimising conventional patterns instead of exploring fundamentally better ones.</p>
<p>The Pie Menu is a perfect illustration of this. According to Fitts’s Law, the time required to reach a target depends on its distance and size. Linear menus place the last item much farther from the cursor than the first, creating uneven interaction costs.</p>
<p>Radial menus position every option at an equal distance from the centre, and their wedge‑shaped targets effectively grow larger as the pointer moves outward.</p>
<p>Mathematically, pie/radial menu are faster to interact with and more efficient — yet they remain rare in mainstream web design because they violate users’ expectations. In other words, Jakob’s Law keeps us locked into a familiar but suboptimal pattern simply because “that’s how it’s always been done.”</p>
<p>But the challenge is not choosing between familiarity and innovation, but balancing them.</p>
<p>This is where the Aesthetic–Usability Effect becomes powerful. Research shows that users perceive attractive interfaces as easier to use, and they are more forgiving of minor usability friction when the design is visually pleasing.</p>
<p>A beautifully crafted Pie Menu, for example, can encourage users to invest the small amount of learning required to use it. By applying aesthetic delight strategically, developers can introduce innovative patterns without overwhelming users.</p>
<p>The principle that emerges is simple: Be conventional where it matters, and innovative where it delights.</p>
<h3 id="heading-design-takeaway-from-jakobs-law">Design Takeaway from Jakob's Law</h3>
<p><strong>Keep Trust‑Critical Elements Predictable:</strong><br>Navigation, search, authentication, and other high‑stakes interactions must follow established conventions. Users rely on these patterns for speed, confidence, and safety — this is where Jakob’s Law should be respected without exception.</p>
<p><strong>Experiment Only in Low‑Risk, High‑Creativity Areas:</strong><br>In creative or productivity‑focused zones — like editing tools in a photo app — you can safely introduce new interaction models such as radial menus, gesture wheels, or context‑aware tool selectors. These areas invite exploration and benefit from efficiency‑driven innovation.</p>
<p><strong>To Sum Up:</strong> Be conventional where it matters, and innovative where it delights.</p>
<h2 id="heading-60-millers-law"><strong>6.0 Miller’s Law</strong></h2>
<p>Miller’s Law originates from George A. Miller’s classic paper “The Magical Number Seven, Plus or Minus Two.” It states that the average person can hold only about 7 (±2) chunks of information in working memory at any given moment (Miller, 1956).</p>
<p>Crucially, Miller emphasised that the brain doesn’t store isolated items — it groups them into meaningful units called chunks. Because working memory is so limited, developers must structure information in ways that respect this cognitive boundary.</p>
<p>This principle has direct implications for interface design. Long, unbroken strings of information overwhelm users, whereas chunked formats are far easier to process.</p>
<p>For example, instead of displaying a phone number as 1234567890, formatting it as 123‑456‑7890 transforms ten digits into three manageable chunks. The same logic applies to navigation: aim for five to nine primary menu items, and if you need more, group them into categories. Users remember the category as a single chunk rather than each individual link.</p>
<p>Miller’s Law also explains why long forms are so intimidating. When a user sees 30 fields on one page, their brain interprets it as a single, massive task — far beyond the 7±2 limit.</p>
<p>A progressive stepper solves this by breaking the form into smaller stages of 5–7 fields each. This reduces cognitive load, creates a sense of progress, and significantly lowers abandonment rates.</p>
<p>The same principle applies to product listings or search results. Expecting users to compare 50 items at once is unrealistic. Instead, provide strong filtering tools so users can narrow the set to a manageable size — ideally within the range their working memory can meaningfully evaluate.</p>
<p>In essence, Miller’s Law reminds developers that humans don’t process information in bulk. They process it in structured, meaningful chunks.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6998def93dc17c4862075045/3add2891-c6f8-4df1-a044-6cea6c5f1945.jpg" alt="Image of progressive stepper" style="display:block;margin:0 auto" width="818" height="960" loading="lazy">

<p><em>Fig 6.0: Illustrating progressive stepper</em></p>
<p>In the example above, the interface uses both a progress bar and a stepper to guide the user through multiple stages of a task. After completing the first page and selecting “Continue,” the user moves to the next step, and the progress bar updates accordingly. This creates a clear sense of forward movement and accomplishment.</p>
<p>By breaking the process into smaller segments, the interface prevents cognitive overload. If all the information were presented on a single page, users might feel overwhelmed, unsure where to begin, or discouraged by the sheer volume of work.</p>
<p>A step‑by‑step flow transforms a large task into a sequence of manageable actions, increasing the likelihood of completion.</p>
<h3 id="heading-design-takeaway-from-millers-law">Design Takeaway from Miller's Law</h3>
<p><strong>Respect the 7±2 Working‑Memory Limit:</strong><br>Users can only hold about seven chunks of information at once. Long, unbroken content overwhelms them, while chunked information is instantly easier to process.</p>
<p><strong>Chunk Information Into Meaningful Units:</strong><br>The brain doesn’t store isolated items — it groups them. Format data (like phone numbers), menus, and settings into clear, memorable chunks instead of long, flat lists.</p>
<p><strong>Keep Navigation Within 5–9 Primary Items:</strong><br>If you need more than nine options, group them into categories. Users remember the category as a single chunk, not each individual link.</p>
<p><strong>Break Long Forms Into Smaller Steps:</strong><br>A 30‑field form feels like one giant task. A progressive stepper with 5–7 fields per step keeps users below the cognitive overload threshold and dramatically reduces abandonment.</p>
<p><strong>Reduce Comparison Load With Strong Filters:</strong><br>Expecting users to compare 50 products at once is unrealistic. Provide filtering tools that shrink the decision set to something the working memory can actually handle.</p>
<p><strong>Design for Chunked Thinking, Not Bulk Processing:</strong><br>Humans don’t process information in bulk — they process structured, meaningful groups. Interfaces that respect this limitation feel lighter, faster, and more intuitive.</p>
<p><strong>To Sum Up:</strong> A step‑by‑step flow transforms a large task into a sequence of manageable actions, increasing the likelihood of completion.</p>
<h2 id="heading-70-the-goal-gradient-hypothesis">7.0 The Goal-Gradient Hypothesis</h2>
<p>This is the perfect moment to introduce the Goal‑Gradient Hypothesis, originally proposed by behaviorist Clark Hull in 1932 (Yablonski, 2022). The hypothesis states that people become more motivated as they get closer to achieving a goal. In other words, users naturally accelerate their engagement when they sense they are nearing completion.</p>
<p>This principle is incredibly powerful in UX design, especially for progress tracking, gamification, and reward systems.</p>
<p>The takeaway is straightforward: Because users are more motivated near the finish line, progress indicators should be prominent and meaningful.</p>
<p>Percentages, progress bars, and step counters reinforce momentum. Micro‑achievements — such as badges, checkmarks, or subtle confetti — amplify motivation by celebrating small wins.</p>
<p>Tasks should be broken into measurable milestones so users can see themselves advancing.</p>
<p>This is why e‑learning platforms display messages like “You’ve completed 8 of 10 lessons — almost there!” and why fitness apps highlight progress with prompts such as “3 km done, 2 km to go.” These cues leverage the goal‑gradient effect to keep users engaged, energized, and eager to finish.</p>
<p>By combining progressive steppers with clear progress feedback, developers create interfaces that feel lighter, more encouraging, and far more motivating — ultimately improving completion rates and overall user satisfaction.</p>
<p>But what happens when a goal isn't completed? Why do we sometimes feel uncomfortable leaving things unfinished? That discomfort is explained by another psychological principle called the Zeigarnik Effect — the tendency for people to remember and feel tension about incomplete tasks. We will look at this next.</p>
<h3 id="heading-design-takeaway-from-goal-gradient-hypothesis">Design Takeaway from Goal-Gradient Hypothesis</h3>
<p><strong>Make Progress Visible to Boost Motivation:</strong><br>According to the Goal‑Gradient Hypothesis, users naturally speed up as they sense they’re nearing completion. Prominent progress bars, percentages, and step counters tap into this instinct and keep momentum high.</p>
<p><strong>Celebrate Micro‑Achievements to Reinforce Engagement:</strong><br>Badges, checkmarks, subtle confetti, and “step completed” cues reward small wins. These micro‑rewards amplify motivation and make long tasks feel lighter and more achievable.</p>
<p><strong>Break Tasks Into Measurable Milestones:</strong><br>Users stay motivated when they can see themselves advancing. Divide complex flows into clear steps so progress feels tangible rather than overwhelming.</p>
<p><strong>Use Progress Feedback to Drive Completion:</strong><br>Messages like “8 of 10 lessons completed — almost there” or “3 km done, 2 km to go” leverage the goal‑gradient effect to energise users and pull them toward the finish line.</p>
<p><strong>Combine Steppers With Clear Feedback for Maximum Impact:</strong><br>Progressive steppers paired with strong visual feedback create interfaces that feel encouraging, structured, and motivating — dramatically improving completion rates.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/-mDuFB3W2bg" 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><em>Video 8.0 : Video illustrating goal gradient</em></p>
<p><strong>To Sum Up:</strong> People become more motivated as they get closer to achieving a goal.</p>
<h2 id="heading-80-zeigarnik-effect"><strong>8.0 Zeigarnik Effect</strong></h2>
<p>The Zeigarnik Effect is a psychological principle stating that people remember unfinished or interrupted tasks better than completed ones (Cherry, 2024).</p>
<p>Memory begins with sensory input, which is processed into short-term memory. Unfinished tasks persist in our thoughts, leading to active recall. This ongoing engagement can turn them into long-term memories, enhancing recall until resolved. This increases engagement, encourages task completion, improves retention, and drives conversions.</p>
<p>So because people remember unfinished tasks better than completed ones (Zeigarnik Effect), developers use progress indicators to make users aware that something is incomplete and motivate them to finish it.</p>
<p>In your designs, you can break long forms into multi-step processes to encourage completion and display profile completion percentages (for example, 70% complete) to push users toward 100%.</p>
<p>This is the main reason why e-commerce platforms send abandoned cart reminders to bring users back to complete their purchases. It's also why apps use streak systems to encourage daily engagement and habit formation and learning platforms show course completion bars to motivate users to finish modules.</p>
<h3 id="heading-design-takeaway-from-zeigarnik-effect">Design Takeaway from Zeigarnik Effect</h3>
<p><strong>Unfinished Tasks Stay Active in Memory — Use That to Drive Completion:</strong><br>Because incomplete tasks linger in working memory (Zeigarnik Effect), users naturally keep thinking about what they haven’t finished. This tension boosts recall, engagement, and the likelihood of returning to complete the task.</p>
<p><strong>Make Incompleteness Visible With Progress Indicators:</strong><br>Progress bars, percentages, and step counters remind users that something is still unfinished. This gentle psychological pressure motivates them to continue until the task is complete.</p>
<p><strong>Break Long Flows Into Multi‑Step Processes:</strong><br>A massive form feels overwhelming, but a stepper with smaller chunks keeps users moving. Showing “70% complete” nudges them toward finishing the last stretch.</p>
<p><strong>Use Reminders to Re‑activate Unfinished Intent:</strong><br>Abandoned cart emails, streak reminders, and “continue your lesson” prompts work because the unfinished task is already active in the user’s mind. The reminder simply pulls them back into the loop.</p>
<p><strong>Celebrate Completion to Close the Cognitive Loop:</strong><br>Checkmarks, confirmations, and completion badges give users closure. This resolves the mental tension created by the unfinished task and reinforces positive behaviour.</p>
<p><strong>To Sum Up:</strong> Unfinished tasks persist in our thoughts, leading to active recall.</p>
<h2 id="heading-90-teslers-law"><strong>9.0 Tesler’s Law</strong>:</h2>
<p>This law was proposed by Lawrence Tesler. He was a computer scientist known for his work on human-computer interaction, and he contributed significantly to making software more user-friendly, including work on cut, copy, and paste functionality.</p>
<p>This law is otherwise known as the Law of Conservation of Complexity. The core Idea here is every process has a certain amount of “inherent complexity" that can't be removed. You can only decide who handles it: the user or the system.</p>
<p>Some examples of these inherent complexities might be:</p>
<ul>
<li><p>translating user actions into correct operations behind the scenes,</p>
</li>
<li><p>handling unreliable or slow network connections,</p>
</li>
<li><p>connecting with third-party APIs, services, or legacy systems,</p>
</li>
<li><p>sorting large datasets quickly,</p>
</li>
<li><p>performing complex search operations</p>
</li>
<li><p>managing version changes and compatibility issues,</p>
</li>
<li><p>managing state, interactions, and animations without confusing the user.</p>
</li>
</ul>
<p>All of these can be inherently complex, but it's the job of the developer to deal with the complexity.</p>
<p>As a developer, you should always try as much as possible to push complexity to the system. For example, instead of making a user type their full address manually, use an Auto-complete API (Google’s Places and Map is best for this). The complexity of finding and validating the address still exists, but the software handles the work for them.</p>
<p>Here's a practical example: let’s say you're designing a student platform that requires users to enter their university name. A practical approach would be to store an array of all universities in the UK in your codebase (This is the hard part Tesla hinted at).</p>
<p>As the user types, they don't need to enter the full name, and their full university name is shown (relating to what they have typed). For instance, if they intend to type “University of Sheffield,” simply typing “Sheff” should prompt the system to display the full university name, which they can then select.</p>
<p>In Dart, you can use a package like fuzzysearch to implement this kind of intelligent matching.</p>
<p>The advantage of this approach is greater than it first appears. It improves data consistency because users often enter the same information in different ways. For example, some users might type “Uni of Sheff,” others “Sheffield University,” and others “Uni of Sheffield,” while all are referring to “University of Sheffield.”</p>
<p>This is how messy data is created, and it creates more work for data analysts. Little wonder that data analysts spend up to 70% of their time cleaning data.</p>
<p>If developers invested more time in structuring how data is collected to ensure consistency, there would be far less work downstream for analysts. This same logic should be applied in how we collect date, time, and other information.</p>
<p>So apart from people's names and email addresses, you should try to standardize the data your app collects as much as possible. Use date and time pickers, stepper controls, input masks, checkboxes, dropdown menu and radio buttons, toggle switches. and so on.</p>
<p>The essence of removing complexity from the user is not only about improving usability, but also about ensuring that the data collected is standardised, structured, and consistent.</p>
<h3 id="heading-design-takeaway-from-teslers-law">Design Takeaway from Tesler's Law</h3>
<p><strong>Push Complexity to the System, Not the User:</strong><br>Every process contains unavoidable complexity. Your job is to handle it behind the scenes so the user experiences the simplest possible interaction.</p>
<p><strong>Automate Tasks Users Shouldn’t Have to Think About:</strong><br>Use tools like autocomplete, fuzzy search, intelligent defaults, and validation APIs to remove manual effort. The complexity still exists — but the system absorbs it instead of the user.</p>
<p><strong>Standardise Inputs to Prevent Messy Data:</strong><br>Users enter the same information in wildly different ways. Use pickers, dropdowns, input masks, radio buttons, and toggles to enforce consistent, structured data collection.</p>
<p><strong>Handle Inherent Technical Complexity Internally:</strong><br>Network issues, API quirks, large dataset sorting, search optimisation, state management, and animation logic are all developer responsibilities. Users should never feel this complexity.</p>
<p><strong>To Sum Up:</strong> Every process contains unavoidable complexity. Your job as a developer is to handle it behind the scenes so the user experiences the simplest possible interaction.</p>
<h2 id="heading-100-peak-end-rule"><strong>10.0 Peak End Rule</strong>:</h2>
<p>In 1993, Daniel Kahneman, Barbara Fredrickson, Charles Schreiber, and Donald Redelmeier invited volunteers into a lab for what sounded like a simple experiment. The task was straightforward: place a hand into a container of painfully cold water (Kahneman et al., 1993)</p>
<p>In the first round, participants kept their hand in 14°C water for 60 seconds. It was uncomfortable, sharp, and unpleasant but after one minute, it was over.</p>
<p>In the second round, they again endured 60 seconds in 14°C water. But this time, they were asked to keep their hand in for an extra 30 seconds. The temperature was raised slightly to 15°C. Still cold. Still unpleasant. Just slightly less intense.</p>
<p>Objectively, the second experience was worse. It lasted 90 seconds instead of 60. More total pain. More suffering.</p>
<p>Later, the researchers asked a simple question:</p>
<p>If you had to repeat one of the trials, which would you choose?” Surprisingly, most participants chose the longer one.</p>
<p>Why would anyone choose more pain?</p>
<p>The researchers realised something profound: people don’t remember experiences by calculating total discomfort. Instead, the mind summarizes the experience using just two key moments — the most intense point (the peak) and the final moment (the end).</p>
<p>In both trials, the peak pain was the same: 14°C. But the longer trial ended slightly better, at 15°C. That small improvement at the end reshaped how the entire episode was remembered. The participants’ “experiencing self” suffered more during the longer trial. But their “remembering self” preferred it because it ended on a less painful note.</p>
<p>From this, the researchers introduced what became known as the Peak–End Rule: we judge experiences largely by their most intense moment and how they finish, not by how long they last.</p>
<p>Since people largely judge an experience by how it ends, developers should focus on designing satisfying confirmation screens and smooth exit interactions. You should concentrate less on making every single moment perfect and instead prioritise optimising the peak and final moments.</p>
<p>A negative ending can overshadow an otherwise good experience, so carefully avoid frustrating final steps such as unexpected fees or confusing confirmations.</p>
<p>Emotional intensity strongly shapes memory, which is why many apps incorporate celebration animations, rewards, or success messages at key moments to leave a lasting positive impression.</p>
<h3 id="heading-design-takeaway-from-peak-end-rule">Design takeaway from Peak End Rule</h3>
<p><strong>People Judge Experiences by the Peak and the Ending — Not the Total Duration:</strong><br>Users don’t remember every moment. They remember the most intense point and how the experience ends. A slightly better ending can completely reshape how the entire interaction is remembered.</p>
<p><strong>Prioritise Strong, Positive Endings in Your UX Flows:</strong><br>A smooth final step, a clear confirmation, or a satisfying success screen leaves a disproportionately strong impression. A bad ending can overshadow an otherwise great experience.</p>
<p><strong>Design for Emotional Peaks at Key Moments:</strong><br>Celebration animations, rewards, checkmarks, and success messages create memorable emotional spikes. These peaks anchor the experience in the user’s memory.</p>
<p><strong>Don’t Try to Perfect Every Moment — Perfect the Right Moments:</strong><br>Optimise the peak and the end of the journey. These two moments define how users recall the entire interaction.</p>
<p><strong>Avoid Negative Surprises at the Finish Line:</strong><br>Unexpected fees, confusing confirmations, or friction at the last step can ruin the memory of the whole process. Protect the ending carefully.</p>
<p><strong>To Sum Up:</strong> We judge experiences largely by their most intense moment and how they finish, not by how long they last.</p>
<h2 id="heading-110-postels-law"><strong>11.0 Postel’s Law</strong>:</h2>
<p>Jon Postel’s famous principle – “Be conservative in what you send, be liberal in what you accept” –&nbsp; is a philosophy of kindness in software design. At its core, the principle argues that systems should be generous with what they accept from users, yet disciplined and predictable in what they output.</p>
<p>When developers follow this approach, users feel supported and understood. When they don’t, users feel punished for being human.</p>
<p>A user’s input is rarely perfect. People type quickly, make mistakes, follow their own habits, or rely on formats familiar to them. A robust system embraces this reality. It accepts messy, human input and quietly transforms it into clean, standardized data.</p>
<p>Real people don't think in strict formats. They write dates the way they learned in school, type phone numbers the way they say them aloud, and enter names and addresses in whatever structure feels natural to them.</p>
<p>A rigid system will reject anything that doesn’t match its narrow expectations, but a robust system, by contrast, adapts to the user.</p>
<p>Consider dates. A brittle interface might demand MM/DD/YYYY and reject everything else. A more humane system accepts a wide range of formats — “1 May 2024,” “2024‑05‑01,” “05/01/24,” or “May 1st, 2024” — and quietly converts them into a standard internal representation. This is where the complex handling described by Tesla's Law comes into play (Shifting complexity to the system, rather than the user).</p>
<p>Phone numbers follow the same pattern. People might enter (555) 123 4567, 555‑123‑4567, 5551234567, or +1 555 123 4567. A fragile system throws errors. A robust one parses all of them using libraries like libphonenumber and moves on.</p>
<p>Addresses are equally varied. “221B Baker St,” “221‑B Baker Street,” and “221 Baker St., Apt B” all refer to the same place. A forgiving system normalizes these instead of rejecting them.</p>
<p>Even names can be surprisingly complex. Hyphens, apostrophes, multiple words, and titles are all part of real human identity. Rejecting “O’Connor,” “Jean‑Luc,” or “Dr. Sarah Lee” is not just technically incorrect — it's disrespectful to the user.</p>
<p>Search bars offer another clear example. A strict search bar demands perfect spelling and exact phrasing. A robust one handles typos (“restuarant”), partial words (“resta”), synonyms (“food places”), and natural language (“where can I eat nearby”). It meets the user where they are instead of forcing them to think like a machine.</p>
<p>Currency should be normalized to a clear format such as GBP 5.00, no matter whether the user typed “£5,” “5 pounds,” or “5 GBP.”</p>
<p>Even file uploads benefit from standardization: whether the user uploads .jpeg, .jpg, .JPG, or .JPEG, the system should store everything as .jpg.</p>
<p>Error messages follow the same principle. Vague feedback like “Invalid password” leaves users confused and frustrated.</p>
<p>A clear, conservative message — “Incorrect password. Please try again.” — respects the user’s time. And instead of hiding password requirements, the system should state them upfront: minimum eight characters, at least one uppercase letter, at least one number.</p>
<p>Predictability reduces friction.</p>
<p>Because users inevitably make mistakes or enter data in unexpected ways, developers should design input fields that are tolerant rather than brittle. This means accepting flexible formats, offering autocorrect or intelligent parsing, and using forgiving validation rules that interpret the user’s intent instead of rejecting their effort.</p>
<p>Clear instructions, tooltips, and visible requirements should appear before submission so users understand what the system expects without trial and error.</p>
<p>When errors do occur, the interface should handle them gently—never crashing, and never forcing the user to start over.</p>
<p>Even simple variations, such as phone numbers typed with spaces, dashes, or parentheses, should be accepted and normalized behind the scenes.</p>
<p>By embracing flexibility on the input side and clarity on the output side, developers create systems that feel humane, resilient, and respectful of the way real people actually behave.</p>
<h3 id="heading-design-takeaway-from-postels-law">Design Takeaway from Postel's Law</h3>
<p><strong>Accept Messy Human Input, Output Clean Structured Data:</strong><br>Users type dates, names, phone numbers, and addresses in unpredictable ways. A humane system accepts this variability and quietly normalises it into a consistent internal format.</p>
<p>Rigid interfaces punish users for being human. Robust interfaces interpret intent — handling typos, partial matches, synonyms, and natural language without complaint.</p>
<p>Also accept variations in spacing, punctuation, casing, and structure. Let users type naturally — the system should handle the complexity, not them.</p>
<p><strong>Be Flexible With Input, Be Strict With Output:</strong><br>This is the heart of Postel’s Law. Let users express information naturally, but ensure your system stores and displays it in a predictable, standardised way.</p>
<p><strong>Use Intelligent Parsing and Autocorrection to Reduce Errors:</strong><br>Libraries like libphonenumber, fuzzy search, and natural‑language parsers allow systems to accept a wide range of formats while still producing clean, reliable data.</p>
<p><strong>Normalise Everything Behind the Scenes:</strong><br>Dates, phone numbers, currency, file extensions, and addresses should all be standardised internally. This prevents messy data and reduces downstream cleanup work.</p>
<p><strong>Provide Clear, Predictable Feedback:</strong><br>Error messages should be specific and helpful. Requirements should be visible upfront. Users should never be surprised, confused, or forced to start over.</p>
<p><strong>Combine Postel’s Law With Tesler’s Law:</strong><br>Shift complexity to the system. Intelligent handling of messy input reduces cognitive load, improves usability, and ensures consistent, high‑quality data.</p>
<p><strong>To Sum Up:</strong> A rigid system will reject anything that doesn’t match its narrow expectations, but a robust system, by contrast, adapts to the user.</p>
<h2 id="heading-120-doherty-threshold"><strong>12.0 Doherty Threshold</strong>:</h2>
<p>The Doherty Threshold is a principle in human–computer interaction which proposes that systems should respond quickly enough to keep users actively engaged (Mod 2024).</p>
<p>When response times stay below a certain limit, users remain focused and productive. But once performance already meets this optimal responsiveness level, making the system even faster or adding extra capability doesn't significantly enhance satisfaction or efficiency.</p>
<p>The idea was introduced by Walter J. Doherty in 1976 in his paper “A Comparison of Programming Systems and Doherty Threshold.” His research showed that maintaining rapid system feedback fast enough to sustain continuous interaction has a stronger impact on productivity than simply increasing system power or features beyond that point.</p>
<p>Doherty proposes that this shouldn't be greater than 400ms Rule: If the system responds within this window, the user feels in total control. If the response takes longer, the user's attention begins to wander, and their "train of thought" is broken.</p>
<p>The challenge, of course, is that not every operation can realistically complete within 400ms. Some tasks require heavy computation, large network calls, or complex rendering. This is where the concept of perceived performance becomes essential.</p>
<p>Even when the system can't finish the work quickly, it can feel fast by responding instantly at the UI level. Developers can achieve this illusion of speed through a combination of thoughtful design patterns and disciplined engineering practices.</p>
<p>On the technical side, performance begins with reducing unnecessary work. Keeping the number of HTML elements low helps the browser render faster. Rendering only the visible portion of long lists prevents the Document Oject Model (DOM) from becoming bloated. Splitting scripts and deferring non‑critical code ensures that essential interactions load first.</p>
<p>Using CSS transforms and opacity changes avoids expensive layout recalculations. Lazy‑loading images, videos, and scripts ensures that the interface becomes interactive long before all assets are downloaded.</p>
<p>These optimizations don’t just improve raw speed — they create the foundation for interfaces that feel responsive.</p>
<h3 id="heading-design-takeaways-from-doherty-threshold">Design Takeaways from Doherty Threshold</h3>
<p><strong>Instant Feedback</strong>: When a user clicks a button, provide a visual change (like a button press animation or a spinner) immediately, even if the background task takes longer.</p>
<p><strong>Skeleton Screens</strong>: Use placeholder blocks that mimic the layout of the page while data loads. This makes the app feel like it is responding instantly.</p>
<p><strong>Progressive Loading</strong>: Load text and basic structures first, then "pop in" high-resolution images later.</p>
<p><strong>Optimistic UI</strong>: When a user hits "Save," don't wait for the server. Update the UI instantly (Doherty) and handle the "messy" data formatting on the backend (Postel).</p>
<p><strong>Live Inline Validation</strong>: Show a green checkmark or a helpful error message as the user types. This keeps them below the 400ms "thought-break" limit.</p>
<p><strong>Debouncing</strong>: In search bars, start showing results after a few keystrokes so the user feels the app is "predicting" their needs.</p>
<p><strong>To Sum Up:</strong> When response times stay below a certain limit, users remain focused and productive. But once performance already meets this optimal responsiveness level, making the system even faster or adding extra capability doesn't significantly enhance satisfaction or efficiency.</p>
<h2 id="heading-130-serial-position-effect-primacy-and-recency"><strong>13.0 Serial Position Effect (Primacy and Recency)</strong>:</h2>
<p>Murdock’s study investigated how the position of a word in a list affects recall, known as the serial position effect. He presented 103 psychology students with lists of 10 to 40 words, one at a time, at either 1 or 2 seconds per word (McLeod, 2025).</p>
<p>Participants were divided into six groups, each experiencing a different combination of list length and presentation rate, and were asked to recall as many words as possible in any order.</p>
<p>The results showed that participants were most likely to remember words at the beginning of the list (primacy effect) and at the end of the list (recency effect), while words in the middle were recalled less often. The recency effect persisted even in longer lists, and the middle section of the recall curve formed a flat asymptote.</p>
<p>Murdock explained this using the multi-store model of memory: early words were rehearsed and transferred to long-term memory, last words remained in short-term memory, and middle words were neither sufficiently rehearsed nor retained, leading to poorer recall.</p>
<p>The experiment demonstrated that memory performance varies systematically with the position of information in a sequence.</p>
<p>This is the reason why the most important information or actions should never be buried in the middle.</p>
<p>As a developer, you should put your most critical navigation links (like "Home" or "Dashboard") at the far left or the top of a list. In a pricing table, put the most popular or recommended plan on the Place "Final Actions" (like "Log Out," "Cart," or "Support") at the end of a menu or the far right of a navigation bar.</p>
<p>In a long onboarding flow, put the most exciting benefit of the app on the very last slide so the user enters the app feeling motivated.</p>
<p>Avoid placing highly important buttons in the middle of a row. If you have a row of 7 buttons, the user is statistically likely to overlook the 4th one.</p>
<h3 id="heading-design-takeaways-serial-position-effect">Design Takeaways <strong>Serial Position Effect</strong></h3>
<p><strong>Place Critical Items at the Beginning or End — Never the Middle:</strong><br>Users reliably remember the first and last items in any sequence (primacy and recency). Anything placed in the middle is statistically more likely to be forgotten or ignored. Also, actions such as “Log Out,” “Cart,” “Support,” or “Checkout” should sit at the far right or bottom — the natural recency position.</p>
<p><strong>Put Essential Navigation Links at the Far Left or Top:</strong><br>Links like “Home,” “Dashboard,” or “Overview” should appear at the start of a menu, where recall and recognition are strongest.</p>
<p><strong>To Sum Up:</strong> The results showed that participants were most likely to remember words at the beginning of the list (primacy effect) and at the end of the list (recency effect), while words in the middle were recalled less often.</p>
<h2 id="heading-140-occams-razor"><strong>14.0 Occam’s Razor</strong>:</h2>
<p>Although first articulated in the 14th century by the Franciscan friar William of Ockham, Occam’s Razor remains one of the most indispensable principles in a developer’s toolkit. In fact, skipping this law while discussing other theories and principles would be like skipping the glue that holds the entire framework together.</p>
<p>At its core, Occam’s Razor states that “among competing explanations, the simplest one is usually the best.”</p>
<p>For example, if two user interfaces achieve the same goal, the one with fewer visual elements is typically superior because it requires less processing power.</p>
<p>The fundamental takeaway for modern developers regarding Occam’s Razor is that complexity is a tax on the user’s cognitive resources.</p>
<p>In an era of information density, the developer's primary role is no longer to provide "more" features – rather, it's to curate the most direct path to a solution.</p>
<p>In practice, Occam’s Razor becomes a reminder to keep things as simple as possible. This “less is more” mindset shapes everything from navigation to forms.</p>
<p>A good rule for navigation is the Rule of Five: aim for three to five main menu items instead of a long, overwhelming list. This keeps choices clear and prevents users from freezing up when they see too many options.</p>
<p>The same idea applies to data entry. When you ask only for the information that truly matters, you respect the user’s time and reduce the chance of “form fatigue,” which is one of the biggest reasons people abandon sign‑ups or checkout flows.</p>
<p>Simplicity isn’t just elegant — it’s practical, humane, and far more effective.</p>
<h3 id="heading-design-takeaway-from-occams-razor">Design Takeaway from Occam's Razor</h3>
<p><strong>Choose the Simplest Effective Solution:</strong><br>When two designs achieve the same goal, the one with fewer elements is almost always better. Simplicity reduces cognitive load and speeds up user decision‑making.</p>
<p><strong>Simplicity Is Not Just Aesthetic — It’s Humane:</strong><br>Clear, minimal interfaces respect the user’s time, reduce friction, and make the product feel effortless. Simplicity is both a design strategy and an act of empathy.</p>
<p><strong>To Sum Up:</strong> Simplicity isn’t just elegant — it’s practical, humane, and far more effective.</p>
<h2 id="heading-150-parkinsons-law">15.0 Parkinson's Law</h2>
<p>Occam’s Razor teaches us to prefer the simplest solution that works. But why do we so often end up with complex systems in the first place? That tendency is explained by another principle: Parkinson’s Law.</p>
<p>Parkinson’s Law states that "work expands to fill the time available for its completion". In design, this means projects often become overly complex or take longer than necessary if given too much time, resulting in inefficient, over-designed, or cluttered interfaces.</p>
<p>In design, this manifests as Feature Creep. If you give yourself three months to build an app, you will spend three months adding "nice-to-have" animations, extra settings toggles, and niche edge cases that nobody asked for and in reality, what you have added isn’t that important.</p>
<p>You just succeeded in adding layers of complexity that might ends up violating some of the laws we spoke about. Occam’s Razor reminds us that the simplest solution is often the most effective.</p>
<p>By being aware of Parkinson’s Law and the tendency for work to expand, developers can manage their time intentionally and focus only on what truly matters.</p>
<h3 id="heading-design-takeaway-for-parkinsons-law">Design Takeaway for Parkinson's law</h3>
<p><strong>Set Clear Constraints to Keep Designs Focused:</strong><br>Intentional time limits and scope boundaries prevent over‑designing. Constraints force clarity, prioritisation, and simplicity.</p>
<p><strong>Build Only What Truly Matters for the User:</strong><br>Parkinson’s Law reminds you to resist the urge to fill time with unnecessary features. Focus on the core experience, not the edge cases nobody asked for.</p>
<p><strong>Use Occam’s Razor to Counterbalance Parkinson’s Law:</strong><br>As work expands, complexity grows. Occam’s Razor pulls you back to the simplest effective solution. Together, the two principles prevent bloated, over‑engineered products.</p>
<p><strong>To Sum Up:</strong> Work expands to fill the time available for its completion</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>Human-centered design is deeply influenced by a set of psychological principles that explain how users perceive, process, and interact with digital systems.</p>
<p>Among these, Fitts’s Law establishes that the time required to acquire a target depends on its size and distance. In practice, this means that larger and closer elements are easier and faster to interact with.</p>
<p>To apply this in practice, developers should make primary call-to-action elements prominent, large, and easily reachable –&nbsp;especially in mobile interfaces where thumb accessibility is critical.</p>
<p>Closely related to decision-making is Hick’s Law, which states that the more choices a user is presented with, the longer it takes to make a decision. Excessive options can overwhelm users and lead to decision fatigue.</p>
<p>To address this, developers should simplify interfaces, minimise unnecessary options, and guide users through processes step-by-step rather than presenting everything at once.</p>
<p>Another important cognitive principle discussed is Miller’s Law, which suggests that the average person can hold approximately seven (plus or minus two) items in working memory at a time. This limitation highlights the need to present information in manageable chunks.</p>
<p>By breaking content into smaller groups and avoiding information overload, developers can improve comprehension and usability.</p>
<p>User expectations are strongly shaped by Jakob’s Law, which says that people spend most of their time on other websites and therefore expect similar patterns across digital products.</p>
<p>Instead of reinventing basic interactions, developers should follow familiar conventions such as placing the logo in the top‑left, the shopping cart in the top‑right, and keeping scrolling behaviour predictable.</p>
<p>But innovation is still possible where it truly adds value. As we discussed with the Aesthetic‑Usability Effect, users are far more tolerant of new or unusual design patterns when the interface is visually appealing and thoughtfully crafted.</p>
<p>The Gestalt Principles provided additional insight into how users visually organise information. The principle of proximity suggests that objects placed close together are perceived as related, so grouping related elements improves clarity. Similarity indicates that elements with consistent colours, shapes, or styles are seen as belonging together, reinforcing visual hierarchy and function. Closure explains that users can perceive incomplete shapes as complete, allowing for minimalistic designs where the brain fills in missing details. Continuity highlights that users naturally follow smooth visual paths, meaning layouts should guide the eye logically through alignment and structure.</p>
<p>We also looked at The Von Restorff Effect which emphasizes that elements which stand out are more likely to be remembered. By using contrast in colour, size, or design, important features such as buttons or alerts can capture user attention.</p>
<p>Managing complexity was addressed by Tesler’s Law, which asserts that every system has inherent complexity that cannot be eliminated but only managed.</p>
<p>Developers must therefore shift complexity away from the user by simplifying interfaces while handling intricate processes behind the scenes.</p>
<p>The Zeigarnik Effect reveals that people remember unfinished tasks better than completed ones, creating a sense of mental tension. This can be leveraged by incorporating progress indicators, checklists, and reminders that encourage users to complete tasks.</p>
<p>Similarly, the Peak-End Rule suggests that users judge an experience based on its most intense moment and its conclusion. Developers should create memorable highlights and ensure a smooth, satisfying ending to user journeys.</p>
<p>We also discussed the Goal-Gradient Effect, which explains that users become more motivated as they approach the completion of a task. By showing progress –such as indicating that a process is “80% complete” –&nbsp;and breaking tasks into stages, developers can encourage users to finish what they have started.</p>
<p>In terms of system interaction, Postel’s Law advises developers to be flexible in accepting user input while maintaining strict standards for output. This means allowing different input formats while ensuring consistent and reliable system responses.</p>
<p>Performance is equally important, as highlighted by the Doherty Threshold, which shows that productivity increases when system response times stay under 400 milliseconds. Fast systems keep users engaged and create a sense of ease.</p>
<p>This means that developers should focus on building interfaces that feel instant, even when real processing takes longer, by combining smart engineering practices with thoughtful design patterns that maintain the illusion of speed.</p>
<p>Memory and attention are further explained by the Serial Position Effect, where users tend to remember the first and last items in a sequence more than those in the middle. Developers should position key information or actions at the beginning or end of lists.</p>
<p>Simplicity is reinforced by Occam’s Razor, which argues that the simplest solution is often the most effective. Eliminating unnecessary features reduces friction and enhances usability, and we further discussed about Parkinson’s Law, which suggests that tasks expand to fill the time available, indicating the importance of setting constraints such as deadlines or timers to encourage timely action.</p>
<p>These principles collectively highlight the importance of simplicity, clarity, performance, and user psychology in design. By applying them thoughtfully, developers can create intuitive, efficient, and engaging user experiences that align with both human behaviour and user expectations.</p>
<h2 id="heading-references">References</h2>
<p>Budiu, R. (2022). <em>Fitts’s Law and Its Applications in UX</em>. [online] Nielsen Norman Group. Available at: <a href="https://www.nngroup.com/articles/fitts-law/">https://www.nngroup.com/articles/fitts-law/</a>.</p>
<p>Bustamante, N. (2023). <em>Gestalt Psychology? Definition, Principles, &amp; Examples - Simply Psychology</em>. [online] <a href="http://www.simplypsychology.org">www.simplypsychology.org</a>. Available at: <a href="https://www.simplypsychology.org/what-is-gestalt-psychology.html">https://www.simplypsychology.org/what-is-gestalt-psychology.html</a>.</p>
<p>Cherry, K. (2024). <em>The Zeigarnik Effect Is Why You Keep Thinking of Unfinished Work</em>. [online] Verywell Mind. Available at: <a href="https://www.verywellmind.com/zeigarnik-effect-memory-overview-4175150">https://www.verywellmind.com/zeigarnik-effect-memory-overview-4175150</a>.</p>
<p>DO, A.M., RUPERT, A.V. and WOLFORD, G. (2008). Evaluations of pleasurable experiences: The peak-end rule. <em>Psychonomic Bulletin &amp; Review</em>, 15(1), pp.96–98. doi:<a href="https://doi.org/10.3758/pbr.15.1.96">https://doi.org/10.3758/pbr.15.1.96</a>.</p>
<p>GUPTA, S., GUPTA, S., MAHENDRA, A. and GUPTA, S. (2006). Inverse Halo Nevus. <em>Dermatologic Surgery</em>, 32(6), pp.871–872. doi:<a href="https://doi.org/10.1097/00042728-200606000-00025">https://doi.org/10.1097/00042728-200606000-00025</a>.</p>
<p>‌Hall, D. (2023). <em>Pilot Error, Chapanis and The Shape of Things to Come</em>. [online] UX Magazine. Available at: <a href="https://uxmag.com/articles/pilot-error-chapanis-and-the-shape-of-things-to-come">https://uxmag.com/articles/pilot-error-chapanis-and-the-shape-of-things-to-come</a>.</p>
<p>Hunt, R.R. (1995). The subtlety of distinctiveness: What von Restorff really did. <em>Psychonomic Bulletin &amp; Review</em>, 2(1), pp.105–112. doi:<a href="https://doi.org/10.3758/bf03214414">https://doi.org/10.3758/bf03214414</a>.</p>
<p>Kahneman, D., Fredrickson, B.L., Schreiber, C.A. and Redelmeier, D.A. (1993). When More Pain Is Preferred to Less: Adding a Better End. <em>Psychological Science</em>, 4(6), pp.401–405. doi:<a href="https://doi.org/10.1111/j.1467-9280.1993.tb00589.x">https://doi.org/10.1111/j.1467-9280.1993.tb00589.x</a>.</p>
<p>Mod, D. (2024). <em>Doherty Threshold: UX Law of Swift Interactions</em>. [online] Articles on everything UX: Research, Testing &amp; Design. Available at: <a href="https://blog.uxtweak.com/doherty-threshold/">https://blog.uxtweak.com/doherty-threshold/</a>.</p>
<p>Miller, G.A. (1956). The magical number seven, plus or minus two: Some limits on our capacity for processing information. <em>Psychological Review</em>, [online] 101(2), pp.343–352. doi:<a href="https://doi.org/10.1037/0033-295x.101.2.343">https://doi.org/10.1037/0033-295x.101.2.343</a>.</p>
<p>Proctor, R.W. and Schneider, D.W. (2018). Hick’s law for choice reaction time: A review. <em>Quarterly Journal of Experimental Psychology</em>, [online] 71(6), pp.1281–1299. doi:<a href="https://doi.org/10.1080/17470218.2017.1322622">https://doi.org/10.1080/17470218.2017.1322622</a>.</p>
<p>Yablonski, J. (2022). <em>Hick’s Law</em>. [online] Laws of UX. Available at: <a href="https://lawsofux.com/hicks-law/">https://lawsofux.com/hicks-law/</a>.</p>
<p>Yablonski, J. (2022). <em>Goal-Gradient Effect</em>. [online] Laws of UX. Available at: <a href="https://lawsofux.com/goal-gradient-effect/">https://lawsofux.com/goal-gradient-effect/</a>.</p>
<p>‌</p>
<p>‌</p>
<p>‌</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Complete SaaS Payment Flow with Stripe, Webhooks, and Email Notifications ]]>
                </title>
                <description>
                    <![CDATA[ Most Stripe tutorials end at the checkout page. The customer clicks "Pay," Stripe processes the charge, and the tutorial congratulates you on integrating payments. But that's only the first 10% of a r ]]>
                </description>
                <link>https://www.freecodecamp.org/news/saas-payment-flow-stripe-webhooks-email/</link>
                <guid isPermaLink="false">69fe0830f239332df4de5722</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Magnus Rødseth ]]>
                </dc:creator>
                <pubDate>Fri, 08 May 2026 15:58:40 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/de7d5c4d-062c-4879-892c-4486c7c461af.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most Stripe tutorials end at the checkout page. The customer clicks "Pay," Stripe processes the charge, and the tutorial congratulates you on integrating payments.</p>
<p>But that's only the first 10% of a real payment system.</p>
<p>What happens after the customer pays? You need to record the purchase in your database, send a confirmation email, and grant product access (a GitHub repo invitation, an API key, a license file). You need to notify yourself as the admin. You need to handle refunds two weeks later and send recovery emails when someone abandons checkout.</p>
<p>This is the complete payment lifecycle, and it's where most SaaS applications break.</p>
<p>This article walks you through building the entire flow, from the "Buy" button to the "Welcome" email and everything in between. Every code example comes from a production application processing real payments. You'll see how to design the database schema, create Stripe products, build the checkout flow, process purchases reliably, handle refunds, recover abandoned carts, and send transactional emails.</p>
<p>Here is what you'll learn:</p>
<ul>
<li><p>How to design a database schema that tracks every stage of a purchase</p>
</li>
<li><p>How to create Stripe products and prices programmatically</p>
</li>
<li><p>How to build a checkout flow with success/cancel handling</p>
</li>
<li><p>How to process webhooks securely with signature verification</p>
</li>
<li><p>How to split post-payment processing into durable, independently retried steps</p>
</li>
<li><p>How to handle full and partial refunds with automatic access revocation</p>
</li>
<li><p>How to recover revenue from abandoned checkouts</p>
</li>
<li><p>How to build transactional email templates with React Email and Resend</p>
</li>
<li><p>How to test the entire flow locally with Stripe CLI and Inngest</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-design-the-payment-database-schema">How to Design the Payment Database Schema</a></p>
</li>
<li><p><a href="#heading-how-to-create-stripe-products-and-prices">How to Create Stripe Products and Prices</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-checkout-flow">How to Build the Checkout Flow</a></p>
</li>
<li><p><a href="#heading-how-to-handle-webhooks-securely">How to Handle Webhooks Securely</a></p>
</li>
<li><p><a href="#heading-how-to-process-purchases-with-durable-background-jobs">How to Process Purchases with Durable Background Jobs</a></p>
</li>
<li><p><a href="#heading-how-to-handle-refunds">How to Handle Refunds</a></p>
</li>
<li><p><a href="#heading-how-to-recover-abandoned-checkouts">How to Recover Abandoned Checkouts</a></p>
</li>
<li><p><a href="#heading-how-to-send-transactional-emails-with-react-email">How to Send Transactional Emails with React Email</a></p>
</li>
<li><p><a href="#heading-how-to-test-the-complete-flow-locally">How to Test the Complete Flow Locally</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along, you should be familiar with:</p>
<ul>
<li><p>TypeScript and Node.js</p>
</li>
<li><p>SQL databases (the examples use PostgreSQL)</p>
</li>
<li><p>React (for email templates)</p>
</li>
<li><p>Basic understanding of webhooks</p>
</li>
</ul>
<p>You don't need prior experience with any of the specific libraries. This handbook explains each one as it appears.</p>
<h3 id="heading-what-you-need-installed">What You Need Installed</h3>
<p>Install these packages to run the code examples:</p>
<pre><code class="language-bash">bun add stripe drizzle-orm @neondatabase/serverless inngest resend @react-email/components
</code></pre>
<p>You'll also need:</p>
<ul>
<li><p>A <a href="https://dashboard.stripe.com/register">Stripe account</a> (test mode is fine)</p>
</li>
<li><p>A <a href="https://neon.tech">Neon</a> PostgreSQL database (or any PostgreSQL instance)</p>
</li>
<li><p>A <a href="https://resend.com">Resend</a> account for sending emails</p>
</li>
<li><p>The <a href="https://stripe.com/docs/stripe-cli">Stripe CLI</a> for local webhook testing</p>
</li>
</ul>
<h3 id="heading-environment-variables">Environment Variables</h3>
<p>Set up these environment variables in your <code>.env</code> file:</p>
<pre><code class="language-bash"># Database
DATABASE_URL=postgresql://...

# Stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...

# Email
RESEND_API_KEY=re_...
EMAIL_FROM="Your App &lt;noreply@mail.yourapp.com&gt;"
ADMIN_EMAIL=you@yourapp.com

# App
BETTER_AUTH_URL=http://localhost:3000
</code></pre>
<h2 id="heading-how-to-design-the-payment-database-schema">How to Design the Payment Database Schema</h2>
<p>Before writing any Stripe code, you need a database schema that can track a purchase through every stage of its lifecycle: creation, completion, partial refund, and full refund.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a694d8d4dc9b42434c218f/6d0650fa-a568-4cb5-8560-8a2414635476.png" alt="Purchase status state machine showing transitions from pending to completed via Stripe webhook, then to refunded or partially refunded" style="display:block;margin:0 auto" width="5504" height="3072" loading="lazy">

<p>A purchase starts as <code>pending</code> when the user clicks "Buy." After Stripe confirms payment, it transitions to <code>completed</code>. From there, it can move to <code>refunded</code> or <code>partially_refunded</code>. Pending purchases that are never completed expire after 24 hours (abandoned carts).</p>
<p>Here is the schema I use in production, defined with <a href="https://orm.drizzle.team">Drizzle ORM</a>. The examples throughout this article grant access to a private GitHub repository because that's what this particular product sells.</p>
<p>Your "grant access" step will be different: upgrading a user to a Pro plan, provisioning API credits, unlocking course content, or activating a subscription. The schema fields and step logic change, but the durable execution pattern is the same.</p>
<pre><code class="language-typescript">// src/lib/db/schema.ts
import {
  boolean,
  integer,
  pgEnum,
  pgTable,
  text,
  timestamp,
  varchar,
} from "drizzle-orm/pg-core";

export const purchaseTierEnum = pgEnum("purchase_tier", ["pro"]);
export const purchaseStatusEnum = pgEnum("purchase_status", [
  "completed",
  "partially_refunded",
  "refunded",
]);

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  name: text("name"),
  image: text("image"),
  githubUsername: text("github_username"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() =&gt; crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() =&gt; users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment_intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  githubAccessGranted: boolean("github_access_granted")
    .notNull()
    .default(false),
  githubInvitationId: text("github_invitation_id"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export type Purchase = typeof purchases.$inferSelect;
export type NewPurchase = typeof purchases.$inferInsert;
</code></pre>
<p>Let me walk through the design decisions behind this schema.</p>
<h3 id="heading-why-three-stripe-id-columns">Why Three Stripe ID Columns?</h3>
<p>The <code>purchases</code> table stores three separate Stripe identifiers: <code>stripeCheckoutSessionId</code>, <code>stripeCustomerId</code>, and <code>stripePaymentIntentId</code>.</p>
<p>Each one serves a different purpose.</p>
<p>The <strong>checkout session ID</strong> is what you receive first. When a customer starts checkout, Stripe creates a session and gives you this ID. You use it to claim the purchase after the customer returns from Stripe's hosted checkout page.</p>
<p>The <code>unique()</code> constraint on this column is your idempotency guard. If someone tries to claim the same session twice, the database rejects the second insert.</p>
<p>The <strong>customer ID</strong> is Stripe's internal identifier for the buyer. You need this to look up the customer's payment history in Stripe's dashboard and to create future checkout sessions pre-filled with their billing info.</p>
<p>The <strong>payment intent ID</strong> is what Stripe sends in refund webhook events. When a <code>charge.refunded</code> event fires, it includes the payment intent ID but not the checkout session ID. Without storing this field, you would have no way to match a refund back to a purchase in your database.</p>
<h3 id="heading-why-track-access-state-in-your-database">Why Track Access State in Your Database</h3>
<p>The <code>githubAccessGranted</code> and <code>githubInvitationId</code> fields might look unnecessary. You could check GitHub's API to see if a user has access. But querying an external API every time you need to check a user's access state is slow, rate-limited, and unreliable.</p>
<p>By tracking access state in your own database, you can answer "does this user have access?" with a single indexed query. You also know whether access was ever granted, which is critical for refund processing. If <code>githubAccessGranted</code> is <code>false</code>, you don't need to revoke anything on refund.</p>
<h3 id="heading-why-a-status-enum-with-three-values">Why a Status Enum with Three Values?</h3>
<p>The <code>purchaseStatusEnum</code> has three values: <code>completed</code>, <code>partially_refunded</code>, and <code>refunded</code>.</p>
<p>This matters for downstream logic. Your dashboard, analytics, support tools, and email sequences all need to know the exact state of a purchase. A partially refunded customer still has access, but a fully refunded customer doesn't.</p>
<p>If you only tracked "refunded" as a boolean, you would lose the distinction between partial and full refunds. That distinction affects whether you revoke product access.</p>
<h3 id="heading-how-to-generate-and-run-migrations">How to Generate and Run Migrations</h3>
<p>After defining your schema, generate a migration file and apply it to your database:</p>
<pre><code class="language-bash"># Generate migration SQL from schema changes
bun run drizzle-kit generate

# Push schema directly (development only)
bun run drizzle-kit push

# Run migrations (production)
bun run drizzle-kit migrate
</code></pre>
<p>Drizzle Kit compares your TypeScript schema to the database and generates the SQL needed to bring them in sync. Review the generated migration file before running it in production. Schema changes are one of the few things you can't easily undo.</p>
<p>For development, <code>drizzle-kit push</code> is faster because it applies changes directly without creating migration files. For production, always use <code>drizzle-kit generate</code> followed by <code>drizzle-kit migrate</code> so you have a versioned record of every schema change.</p>
<h2 id="heading-how-to-create-stripe-products-and-prices">How to Create Stripe Products and Prices</h2>
<p>You can create products and prices through the Stripe dashboard, but managing them programmatically is better for reproducibility. Here's a seed script that creates everything you need:</p>
<pre><code class="language-typescript">// src/lib/payments/seed.ts
import { stripe } from "./index";

const PRODUCTS = [
  {
    name: "My SaaS Product",
    description: "Full access, one-time purchase",
    features: [
      "Full source code access",
      "Production-ready infrastructure",
      "Lifetime updates",
    ],
    metadata: { tier: "pro" },
    prices: [
      {
        lookupKey: "pro_one_time",
        unitAmount: 19900, // $199.00 in cents
        currency: "usd",
        nickname: "Pro One-Time",
      },
    ],
  },
];

async function main() {
  console.log("Seeding Stripe products and prices...\n");

  for (const config of PRODUCTS) {
    // Create or find product
    const products = await stripe.products.list({ active: true, limit: 100 });
    let product = products.data.find((p) =&gt; p.name === config.name);

    if (!product) {
      product = await stripe.products.create({
        name: config.name,
        description: config.description,
        marketing_features: config.features.map((f) =&gt; ({ name: f })),
        metadata: config.metadata,
      });
      console.log(`Created product "\({config.name}" (\){product.id})`);
    }

    // Create prices
    for (const priceConfig of config.prices) {
      const existing = await stripe.prices.list({
        lookup_keys: [priceConfig.lookupKey],
        active: true,
        limit: 1,
      });

      if (existing.data[0]) {
        console.log(`Price "${priceConfig.lookupKey}" already exists`);
        continue;
      }

      const price = await stripe.prices.create({
        product: product.id,
        unit_amount: priceConfig.unitAmount,
        currency: priceConfig.currency,
        nickname: priceConfig.nickname,
        lookup_key: priceConfig.lookupKey,
        transfer_lookup_key: true,
      });

      console.log(`Created price "\({priceConfig.lookupKey}" (\){price.id})`);
    }
  }

  console.log("\nDone! Add the price ID to your .env as STRIPE_PRO_PRICE_ID");
}

main().catch(console.error);
</code></pre>
<p>Run this with <code>bun run src/lib/payments/seed.ts</code>.</p>
<p>A few things worth noting.</p>
<ul>
<li><p><strong>Use</strong> <code>lookup_key</code> <strong>instead of hardcoding price IDs:</strong> Price IDs are different between test and live mode. Lookup keys let you reference prices by name (<code>pro_one_time</code>) rather than by Stripe's generated ID (<code>price_1P...</code>).  </p>
<p>The <code>transfer_lookup_key: true</code> option ensures that if you create a new price with the same lookup key, it replaces the old one automatically.</p>
</li>
<li><p><strong>Prices are in cents:</strong> Stripe's API expects amounts in the smallest currency unit. For USD, that means <code>19900</code> represents $199.00.  </p>
<p>This is a common source of bugs. Always store amounts in cents in your database and convert to dollars only at the display layer.</p>
</li>
<li><p><strong>The seed script is idempotent:</strong> You can run it multiple times safely. It checks for existing products and prices before creating new ones.</p>
</li>
</ul>
<h3 id="heading-how-to-set-up-the-stripe-client">How to Set Up the Stripe Client</h3>
<p>The Stripe client uses lazy initialization so that importing it doesn't throw if the API key is missing at module load time. This matters in build environments where environment variables aren't set.</p>
<pre><code class="language-typescript">// src/lib/payments/index.ts
import Stripe from "stripe";

let stripeClient: Stripe | null = null;

function getStripe(): Stripe {
  if (!stripeClient) {
    const secretKey = process.env.STRIPE_SECRET_KEY;
    if (!secretKey) {
      throw new Error("STRIPE_SECRET_KEY is not set");
    }
    stripeClient = new Stripe(secretKey);
  }
  return stripeClient;
}

export const stripe = new Proxy({} as Stripe, {
  get(_, prop) {
    return Reflect.get(getStripe(), prop);
  },
});
</code></pre>
<p>The <code>Proxy</code> wrapper is the key pattern here. Code across your application imports <code>stripe</code> and calls methods like <code>stripe.checkout.sessions.create(...)</code>. The proxy intercepts every property access and forwards it to the lazily initialized client.</p>
<p>This means the Stripe SDK only initializes when you actually use it, not when the module is imported.</p>
<h2 id="heading-how-to-build-the-checkout-flow">How to Build the Checkout Flow</h2>
<p>The checkout flow has three parts: creating the session, redirecting the customer, and handling the return.</p>
<h3 id="heading-how-to-create-a-checkout-session">How to Create a Checkout Session</h3>
<p>Here's the function that creates a Stripe Checkout session for a one-time payment:</p>
<pre><code class="language-typescript">// src/lib/payments/index.ts
export async function createOneTimeCheckoutSession(params: {
  priceId: string;
  successUrl: string;
  cancelUrl: string;
  metadata: Record&lt;string, string&gt;;
  customerEmail?: string;
  couponId?: string;
}) {
  const client = getStripe();

  const session = await client.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: params.priceId, quantity: 1 }],
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    metadata: params.metadata,
    ...(params.customerEmail &amp;&amp; {
      customer_email: params.customerEmail,
    }),
    ...(params.couponId
      ? { discounts: [{ coupon: params.couponId }] }
      : { allow_promotion_codes: true }),
  });

  return session;
}
</code></pre>
<p>Three details matter here.</p>
<ul>
<li><p><strong>The</strong> <code>mode: "payment"</code> <strong>setting tells Stripe this is a one-time charge</strong>, not a subscription. For subscriptions, you would use <code>mode: "subscription"</code>. The mode affects which webhook events Stripe sends after payment.</p>
</li>
<li><p><strong>The</strong> <code>metadata</code> <strong>field is how you link the Stripe session back to your application.</strong> Pass your internal product tier, user ID, or any other data you need after payment. Stripe stores this metadata and includes it in webhook events and API responses.</p>
</li>
<li><p><strong>The</strong> <code>allow_promotion_codes: true</code> <strong>option shows a promo code field on the checkout page.</strong> If you have a specific coupon to apply (from a landing page URL parameter, for example), pass it via <code>discounts</code> instead. You can't use both at the same time.</p>
</li>
</ul>
<h3 id="heading-how-to-create-the-checkout-api-endpoint">How to Create the Checkout API Endpoint</h3>
<p>Here's the API endpoint that creates a checkout session and returns the URL:</p>
<pre><code class="language-typescript">// src/server/api.ts
app.post("/api/payments/checkout", async ({ set }) =&gt; {
  const priceId = process.env.STRIPE_PRO_PRICE_ID;

  if (!priceId) {
    set.status = 500;
    return { error: "Price not configured" };
  }

  const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
  const tier = "pro";

  const checkoutSession = await createOneTimeCheckoutSession({
    priceId,
    successUrl: `${baseUrl}/dashboard?purchase=success&amp;session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${baseUrl}/pricing`,
    metadata: { tier },
  });

  return { url: checkoutSession.url };
});
</code></pre>
<p>The <code>{CHECKOUT_SESSION_ID}</code> placeholder in the success URL is a Stripe template variable. Stripe replaces it with the actual session ID when redirecting the customer. This lets your frontend know which session just completed.</p>
<h3 id="heading-how-to-claim-the-purchase-after-checkout">How to Claim the Purchase After Checkout</h3>
<p>When the customer returns to your success URL, your frontend reads the <code>session_id</code> from the URL and sends it to a "claim" endpoint. This endpoint verifies the payment and creates the purchase record.</p>
<pre><code class="language-typescript">// src/server/api.ts
app.post(
  "/api/purchases/claim",
  async ({ body, request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    const { sessionId } = body;

    // Check if this session was already claimed
    const existing = await db
      .select()
      .from(purchases)
      .where(eq(purchases.stripeCheckoutSessionId, sessionId))
      .limit(1);

    if (existing[0]) {
      return { success: true, alreadyClaimed: true, tier: existing[0].tier };
    }

    // Retrieve the Stripe checkout session to verify payment
    const stripeSession = await retrieveCheckoutSession(sessionId);

    if (stripeSession.payment_status !== "paid") {
      set.status = 400;
      return { error: "Payment not completed" };
    }

    const tier = (stripeSession.metadata?.tier ?? "pro") as PaymentTier;

    // Create purchase record
    await db.insert(purchases).values({
      userId: session.user.id,
      stripeCheckoutSessionId: sessionId,
      stripeCustomerId:
        typeof stripeSession.customer === "string"
          ? stripeSession.customer
          : stripeSession.customer?.id ?? null,
      stripePaymentIntentId:
        typeof stripeSession.payment_intent === "string"
          ? stripeSession.payment_intent
          : stripeSession.payment_intent?.id ?? null,
      tier,
      status: "completed",
      amount: stripeSession.amount_total ?? 0,
      currency: stripeSession.currency ?? "usd",
    });

    // Trigger background processing
    await inngest.send({
      name: "purchase/completed",
      data: {
        userId: session.user.id,
        tier,
        sessionId,
      },
    });

    return { success: true, tier };
  },
  {
    body: t.Object({
      sessionId: t.String(),
    }),
  }
);
</code></pre>
<p>This endpoint does four things, in order.</p>
<ol>
<li><p><strong>First, it checks if the session was already claimed.</strong> The <code>unique()</code> constraint on <code>stripeCheckoutSessionId</code> in the schema prevents duplicate records, but checking first lets you return a clean response without catching a database error.</p>
</li>
<li><p><strong>Second, it verifies payment with Stripe.</strong> Never trust data from the client. The frontend passes the session ID, but you must call Stripe's API to confirm that <code>payment_status</code> is <code>"paid"</code>.</p>
</li>
<li><p><strong>Third, it creates the purchase record.</strong> Notice how it extracts the <code>customer</code> and <code>payment_intent</code> from the Stripe session. Both fields are returned as either strings or expanded objects depending on your Stripe API settings, so the ternary handles both cases.</p>
</li>
<li><p><strong>Fourth, it sends a</strong> <code>purchase/completed</code> <strong>event to Inngest.</strong> This triggers the background processing flow that handles emails, access grants, analytics, and follow-up scheduling. The API endpoint doesn't do any of that work and returns <code>{ success: true }</code> immediately.</p>
</li>
</ol>
<p>This separation between recording the purchase and processing it is fundamental. The database insert is fast and reliable. The downstream processing (emails, API calls, analytics) is slow and unreliable.</p>
<p>By splitting them, you ensure the customer sees a success response instantly while the background work happens durably.</p>
<h2 id="heading-how-to-handle-webhooks-securely">How to Handle Webhooks Securely</h2>
<p>Your webhook endpoint is the entry point for Stripe events that happen outside your checkout flow: refunds, expired sessions, and disputes.</p>
<h3 id="heading-how-to-verify-webhook-signatures">How to Verify Webhook Signatures</h3>
<p>Every webhook from Stripe includes a signature header. You must verify this signature before processing the event. Without verification, anyone could send fake events to your webhook URL.</p>
<pre><code class="language-typescript">// src/lib/payments/index.ts
export async function constructWebhookEvent(
  payload: string | Buffer,
  signature: string
) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) {
    throw new Error("STRIPE_WEBHOOK_SECRET is not set");
  }
  const client = getStripe();
  return client.webhooks.constructEventAsync(payload, signature, webhookSecret);
}
</code></pre>
<p>One critical detail: <strong>use</strong> <code>constructEventAsync</code> <strong>instead of</strong> <code>constructEvent</code><strong>.</strong> The async version uses the Web Crypto API, which is compatible with modern runtimes like Bun and Cloudflare Workers. The synchronous version depends on Node.js's <code>crypto</code> module, which isn't available everywhere.</p>
<p>Another critical detail: <strong>pass the raw request body to signature verification.</strong> If your framework parses the body as JSON before you access it, the signature check fails. The signature is computed over the raw bytes of the request, not the parsed JSON.</p>
<h3 id="heading-how-to-build-the-webhook-endpoint">How to Build the Webhook Endpoint</h3>
<p>Here is the production webhook handler. Its only job is to validate the event and route it to the background job system.</p>
<pre><code class="language-typescript">// src/server/api.ts
app.post("/api/payments/webhook", async ({ request, set }) =&gt; {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  if (!sig) {
    set.status = 400;
    return { error: "Missing signature" };
  }

  try {
    const event = await constructWebhookEvent(body, sig);
    console.log(`[Webhook] Received ${event.type}`);

    if (event.type === "charge.refunded") {
      const charge = event.data.object as {
        id: string;
        payment_intent: string;
        amount: number;
        amount_refunded: number;
        currency: string;
      };
      await inngest.send({
        name: "stripe/charge.refunded",
        data: {
          chargeId: charge.id,
          paymentIntentId: charge.payment_intent,
          amountRefunded: charge.amount_refunded,
          originalAmount: charge.amount,
          currency: charge.currency,
        },
      });
    }

    if (event.type === "checkout.session.expired") {
      const session = event.data.object as {
        id: string;
        customer_email: string | null;
      };
      await inngest.send({
        name: "stripe/checkout.session.expired",
        data: {
          sessionId: session.id,
          customerEmail: session.customer_email,
        },
      });
    }

    return { received: true };
  } catch (error) {
    console.error("[Webhook] Stripe verification failed:", error);
    set.status = 400;
    return { error: "Webhook verification failed" };
  }
});
</code></pre>
<p>This is the "thin webhook handler" pattern. Notice what it does <strong>not</strong> do: it does not query the database, send emails, grant access, or call any external service. It validates the signature, extracts the fields it needs, and sends a typed event to Inngest.</p>
<p>The entire handler completes in milliseconds.</p>
<p>Why does this matter? Stripe expects your webhook to return a 2xx response within about 20 seconds. If your handler tries to do too much work (database queries, email sends, API calls), it risks timing out.</p>
<p>Stripe marks it as failed and retries the entire event. Now you have partial completion and duplicate processing.</p>
<p>The thin handler avoids this entirely. Validate, enqueue, return. All the real work happens asynchronously in durable background functions.</p>
<h3 id="heading-why-extract-fields-before-enqueueing">Why Extract Fields Before Enqueueing?</h3>
<p>You might notice that the webhook handler extracts specific fields from the Stripe event before sending them to Inngest:</p>
<pre><code class="language-typescript">await inngest.send({
  name: "stripe/charge.refunded",
  data: {
    chargeId: charge.id,
    paymentIntentId: charge.payment_intent,
    amountRefunded: charge.amount_refunded,
    originalAmount: charge.amount,
    currency: charge.currency,
  },
});
</code></pre>
<p>Why not forward the entire Stripe event? Two reasons.</p>
<p>First, Stripe event objects are large and deeply nested. Your background function only needs five fields. Sending the entire object means your durable function stores a large payload at every checkpoint, and over thousands of runs, this adds up.</p>
<p>Second, extracting fields at the boundary creates a clean contract between your webhook handler and your background functions. If Stripe changes the shape of their event objects in a future API version, you only need to update the extraction logic in the webhook handler. Your background functions keep working because they depend on your own typed data shape, not Stripe's.</p>
<h3 id="heading-how-to-set-up-webhooks-in-production">How to Set Up Webhooks in Production</h3>
<p>For production, you configure webhooks in the Stripe Dashboard:</p>
<ol>
<li><p>Go to Stripe Dashboard, then Developers, then Webhooks.</p>
</li>
<li><p>Add an endpoint pointing to your production URL: <code>https://yourapp.com/api/payments/webhook</code>.</p>
</li>
<li><p>Select the events you want to receive: <code>charge.refunded</code> and <code>checkout.session.expired</code>.</p>
</li>
<li><p>Copy the signing secret and add it to your production environment variables as <code>STRIPE_WEBHOOK_SECRET</code>.</p>
</li>
</ol>
<p>The production signing secret is different from the one the Stripe CLI generates for local testing. Make sure your environment variables are set correctly for each environment.</p>
<h3 id="heading-which-webhook-events-to-listen-for">Which Webhook Events to Listen For</h3>
<p>For a complete payment flow, you need these webhook events configured in Stripe:</p>
<table>
<thead>
<tr>
<th>Event</th>
<th>When It Fires</th>
<th>What You Do</th>
</tr>
</thead>
<tbody><tr>
<td><code>charge.refunded</code></td>
<td>Customer receives a refund</td>
<td>Revoke access (full refund) or update status (partial)</td>
</tr>
<tr>
<td><code>checkout.session.expired</code></td>
<td>Checkout session times out (24 hours)</td>
<td>Send abandoned cart recovery email</td>
</tr>
</tbody></table>
<p>For subscription-based billing, you would also listen for <code>customer.subscription.updated</code>, <code>customer.subscription.deleted</code>, and <code>invoice.payment_failed</code>. This article covers one-time payments, so the examples focus on the two events above.</p>
<p>The <code>checkout.session.completed</code> event is notably absent. For one-time payments, you typically process the purchase in the "claim" endpoint (shown in the previous section) rather than in a webhook, because you need the authenticated user's session to link the purchase to their account.</p>
<h2 id="heading-how-to-process-purchases-with-durable-background-jobs">How to Process Purchases with Durable Background Jobs</h2>
<p>This is the heart of the payment flow. After the purchase record is created and the <code>purchase/completed</code> event is sent, a durable function takes over and runs the entire post-payment workflow.</p>
<p>Each step in this function is individually checkpointed. If step 5 fails, steps 1 through 4 don't re-run. Step 5 retries on its own, and once it succeeds, steps 6 through 9 continue.</p>
<p>This is what "durable execution" means. It's the difference between a payment system that works in development and one that works in production.</p>
<p>I use <a href="https://www.inngest.com/">Inngest</a> for this. It is an event-driven durable execution platform that provides step-level checkpointing out of the box. You define functions with <code>step.run()</code> blocks, and Inngest handles retry logic, state persistence, and observability.</p>
<p>The Inngest client setup is minimal:</p>
<pre><code class="language-typescript">// src/lib/jobs/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-app",
});
</code></pre>
<p>Register your functions with the Inngest serve handler so the dev server (and production) can discover them:</p>
<pre><code class="language-typescript">import { serve } from "inngest/bun";
import { inngest } from "@/lib/jobs/client";
import { stripeFunctions } from "@/lib/jobs/functions/stripe";

const inngestHandler = serve({
  client: inngest,
  functions: [...stripeFunctions],
});

// Mount on your API
app.all("/api/inngest", async (ctx) =&gt; {
  return inngestHandler(ctx.request);
});
</code></pre>
<p>Here's the complete purchase function:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
import { eq } from "drizzle-orm";
import { createElement } from "react";

import { inngest } from "../client";
import { trackServerEvent } from "@/lib/analytics/server";
import { brand } from "@/lib/brand";
import { db, purchases, users } from "@/lib/db";
import {
  sendEmail,
  PurchaseConfirmationEmail,
  AdminPurchaseNotificationEmail,
  RepoAccessGrantedEmail,
} from "@/lib/email";
import { addCollaborator } from "@/lib/github";

export const handlePurchaseCompleted = inngest.createFunction(
  { id: "purchase-completed", triggers: [{ event: "purchase/completed" }] },
  async ({ event, step }) =&gt; {
    const { userId, tier, sessionId } = event.data as {
      userId: string;
      tier: string;
      sessionId: string;
    };

    // Step 1: Look up user and purchase details
    const { user, purchase } = await step.run(
      "lookup-user-and-purchase",
      async () =&gt; {
        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, userId))
          .limit(1);

        const foundUser = userResult[0];
        if (!foundUser) {
          throw new Error(`User not found: ${userId}`);
        }

        const purchaseResult = await db
          .select({
            amount: purchases.amount,
            currency: purchases.currency,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
          })
          .from(purchases)
          .where(eq(purchases.stripeCheckoutSessionId, sessionId))
          .limit(1);

        const foundPurchase = purchaseResult[0];

        return {
          user: foundUser,
          purchase: foundPurchase ?? {
            amount: 0,
            currency: "usd",
            stripePaymentIntentId: null,
          },
        };
      }
    );

    // Step 2: Track purchase in analytics
    await step.run("track-purchase-to-posthog", async () =&gt; {
      try {
        await trackServerEvent(userId, "purchase_completed_server", {
          tier,
          amount_cents: purchase.amount,
          currency: purchase.currency,
          stripe_session_id: sessionId,
          stripe_payment_intent_id: purchase.stripePaymentIntentId,
        });
      } catch (error) {
        console.error(`Failed to track to PostHog:`, error);
      }
    });

    // Step 3: Send purchase confirmation to customer
    await step.run("send-purchase-confirmation", async () =&gt; {
      await sendEmail({
        to: user.email,
        subject: `Your ${brand.name} purchase is confirmed!`,
        template: createElement(PurchaseConfirmationEmail, {
          amount: purchase.amount,
          currency: purchase.currency,
          customerEmail: user.email,
        }),
      });
    });

    // Step 4: Send admin notification
    await step.run("send-admin-notification", async () =&gt; {
      const adminEmail = process.env.ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `New template sale: ${user.email}`,
        template: createElement(AdminPurchaseNotificationEmail, {
          amount: purchase.amount,
          currency: purchase.currency,
          customerEmail: user.email,
          customerName: user.name,
          stripeSessionId: purchase.stripePaymentIntentId ?? sessionId,
        }),
      });
    });

    // Early return if user has no GitHub username
    if (!user.githubUsername) {
      return { success: true, userId, tier, githubAccessGranted: false };
    }

    // Step 5: Grant GitHub repository access
    const collaboratorResult = await step.run(
      "add-github-collaborator",
      async () =&gt; {
        return addCollaborator(user.githubUsername!);
      }
    );

    // Step 6: Track GitHub access granted
    await step.run("track-github-access", async () =&gt; {
      await trackServerEvent(userId, "github_access_granted", {
        tier,
        github_username: user.githubUsername,
        invitation_status: collaboratorResult.status,
      });
    });

    // Step 7: Update purchase record
    await step.run("update-purchase-record", async () =&gt; {
      await db
        .update(purchases)
        .set({
          githubAccessGranted: true,
          githubInvitationId: collaboratorResult.status,
          updatedAt: new Date(),
        })
        .where(eq(purchases.stripeCheckoutSessionId, sessionId));
    });

    // Step 8: Send repo access email
    await step.run("send-repo-access-email", async () =&gt; {
      const repoUrl = brand.social.github;
      await sendEmail({
        to: user.email,
        subject: `Your ${brand.name} repository access is ready!`,
        template: createElement(RepoAccessGrantedEmail, { repoUrl }),
      });
    });

    // Step 9: Schedule follow-up email sequence
    await step.run("schedule-follow-up", async () =&gt; {
      const purchaseRecord = await db
        .select({ id: purchases.id })
        .from(purchases)
        .where(eq(purchases.stripeCheckoutSessionId, sessionId))
        .limit(1);

      if (purchaseRecord[0]) {
        await inngest.send({
          name: "purchase/follow-up.scheduled",
          data: {
            userId,
            purchaseId: purchaseRecord[0].id,
            tier,
          },
        });
      }
    });

    return { success: true, userId, tier, githubAccessGranted: true };
  }
);
</code></pre>
<p>That's a lot of code. Let me break down why each step exists and why it must be separate.</p>
<h3 id="heading-step-1-look-up-user-and-purchase">Step 1: Look Up User and Purchase</h3>
<pre><code class="language-typescript">const { user, purchase } = await step.run(
  "lookup-user-and-purchase",
  async () =&gt; {
    // Database queries for user and purchase records
    return { user: foundUser, purchase: foundPurchase };
  }
);
</code></pre>
<p>This step queries the database for the user and purchase details. Every subsequent step depends on these values (the user's email, the purchase amount, the user's GitHub username).</p>
<p>Because this is wrapped in <code>step.run()</code>, the return value is cached by Inngest. If a later step fails and the function retries, this step doesn't re-run. The cached values are replayed instead.</p>
<p>If the user doesn't exist in the database, this step throws an error that halts the entire function. There's no point continuing if the user can't be found.</p>
<h3 id="heading-step-2-track-analytics">Step 2: Track Analytics</h3>
<pre><code class="language-typescript">await step.run("track-purchase-to-posthog", async () =&gt; {
  try {
    await trackServerEvent(userId, "purchase_completed_server", {
      tier,
      amount_cents: purchase.amount,
      currency: purchase.currency,
    });
  } catch (error) {
    console.error(`Failed to track to PostHog:`, error);
  }
});
</code></pre>
<p>Analytics tracking gets its own step because analytics services have their own failure modes. PostHog could be rate-limited or temporarily unreachable. If that happens, you don't want it to block the confirmation email.</p>
<p>Notice the try-catch. A tracking failure logs the error but doesn't halt the function. Analytics data is valuable but not critical to the purchase flow.</p>
<h3 id="heading-steps-3-and-4-email-notifications">Steps 3 and 4: Email Notifications</h3>
<p>The customer confirmation and admin notification are separate steps because they are independent operations. If Resend returns a 500 when sending the admin email, the customer should still get their confirmation.</p>
<pre><code class="language-typescript">// Step 3: Customer confirmation
await step.run("send-purchase-confirmation", async () =&gt; {
  await sendEmail({
    to: user.email,
    subject: `Your ${brand.name} purchase is confirmed!`,
    template: createElement(PurchaseConfirmationEmail, {
      amount: purchase.amount,
      currency: purchase.currency,
      customerEmail: user.email,
    }),
  });
});

// Step 4: Admin notification
await step.run("send-admin-notification", async () =&gt; {
  const adminEmail = process.env.ADMIN_EMAIL;
  if (!adminEmail) return;

  await sendEmail({
    to: adminEmail,
    subject: `New template sale: ${user.email}`,
    template: createElement(AdminPurchaseNotificationEmail, {
      // ... admin-specific fields
    }),
  });
});
</code></pre>
<p>The admin notification step includes a guard: if <code>ADMIN_EMAIL</code> isn't set, it returns early. This makes the function work in development environments where you haven't configured all environment variables.</p>
<h3 id="heading-step-5-grant-product-access">Step 5: Grant Product Access</h3>
<pre><code class="language-typescript">if (!user.githubUsername) {
  return { success: true, userId, tier, githubAccessGranted: false };
}

const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () =&gt; {
    return addCollaborator(user.githubUsername!);
  }
);
</code></pre>
<p>This is the step most likely to fail. GitHub's API has rate limits, can time out, and the user's GitHub username might be invalid.</p>
<p>By making it its own step, a GitHub API failure doesn't re-trigger the confirmation email (step 3) or the admin notification (step 4). Those are already checkpointed.</p>
<p>Notice the early return before step 5. If the user has no GitHub username linked, the function returns after step 4. The remaining steps only run when there's a GitHub account to grant access to.</p>
<h3 id="heading-steps-6-7-track-and-update">Steps 6-7: Track and Update</h3>
<p>After granting GitHub access, the function tracks the event in analytics (step 6) and updates the purchase record in the database (step 7).</p>
<p>The database update is intentionally ordered after the GitHub API call. You only set <code>githubAccessGranted: true</code> after the invitation actually succeeded. If you updated the record first and the GitHub step failed, your database would say access was granted when it was not.</p>
<h3 id="heading-step-8-send-access-email">Step 8: Send Access Email</h3>
<pre><code class="language-typescript">await step.run("send-repo-access-email", async () =&gt; {
  const repoUrl = brand.social.github;
  await sendEmail({
    to: user.email,
    subject: `Your ${brand.name} repository access is ready!`,
    template: createElement(RepoAccessGrantedEmail, { repoUrl }),
  });
});
</code></pre>
<p>This email only sends after the GitHub invitation is confirmed. The ordering is deliberate. You don't tell the customer "your access is ready" if the invitation hasn't been sent.</p>
<h3 id="heading-step-9-schedule-follow-up-sequence">Step 9: Schedule Follow-Up Sequence</h3>
<pre><code class="language-typescript">await step.run("schedule-follow-up", async () =&gt; {
  const purchaseRecord = await db
    .select({ id: purchases.id })
    .from(purchases)
    .where(eq(purchases.stripeCheckoutSessionId, sessionId))
    .limit(1);

  if (purchaseRecord[0]) {
    await inngest.send({
      name: "purchase/follow-up.scheduled",
      data: {
        userId,
        purchaseId: purchaseRecord[0].id,
        tier,
      },
    });
  }
});
</code></pre>
<p>The final step triggers a separate function that handles the follow-up email sequence: day 7 onboarding tips, day 14 feedback request, day 30 testimonial request. This is an event-driven chain: one function completes and triggers another.</p>
<p>The follow-up function uses <code>step.sleep()</code> to wait between emails without consuming compute resources:</p>
<pre><code class="language-typescript">export const handlePurchaseFollowUp = inngest.createFunction(
  {
    id: "purchase-follow-up",
    triggers: [{ event: "purchase/follow-up.scheduled" }],
    cancelOn: [
      {
        event: "purchase/follow-up.cancelled",
        match: "data.purchaseId",
      },
    ],
  },
  async ({ event, step }) =&gt; {
    await step.sleep("wait-7-days", "7d");
    await step.run("send-day-7-email", async () =&gt; {
      // Send onboarding tips
    });

    await step.sleep("wait-14-days", "7d");
    await step.run("send-day-14-email", async () =&gt; {
      // Send feedback request
    });
  }
);
</code></pre>
<p>The <code>cancelOn</code> option is worth noting. If the purchase is refunded, you send a <code>purchase/follow-up.cancelled</code> event, and the entire follow-up sequence stops. No stale emails to customers who refunded.</p>
<h3 id="heading-the-rule-for-step-separation">The Rule for Step Separation</h3>
<p>Any operation that calls an external service or could fail independently should be its own step. A database query is a step because the database can be temporarily unreachable. An email send or API call is a step because those services can return errors or hit rate limits.</p>
<p>If two operations always succeed or fail together, they can share a step. But when in doubt, make it separate. The overhead is negligible, and the reliability gain is significant.</p>
<h2 id="heading-how-to-handle-refunds">How to Handle Refunds</h2>
<p>Refund processing is the most commonly overlooked part of a payment system. You need to handle two cases: full refunds (revoke access) and partial refunds (keep access, update status).</p>
<p>Here's the complete refund handler:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
export const handleRefund = inngest.createFunction(
  { id: "refund-processed", triggers: [{ event: "stripe/charge.refunded" }] },
  async ({ event, step }) =&gt; {
    const data = event.data as {
      chargeId: string;
      paymentIntentId: string;
      amountRefunded: number;
      originalAmount: number;
      currency: string;
    };

    const chargeId = data.chargeId;
    const paymentIntentId = data.paymentIntentId;
    const currency = data.currency;
    const amountRefunded = data.amountRefunded;
    const originalAmount = data.originalAmount;
    const isFullRefund = amountRefunded &gt;= originalAmount;

    // Step 1: Look up the purchase and user
    const { user, purchase } = await step.run(
      "lookup-purchase-by-payment-intent",
      async () =&gt; {
        const purchaseResult = await db
          .select({
            id: purchases.id,
            userId: purchases.userId,
            stripePaymentIntentId: purchases.stripePaymentIntentId,
            githubAccessGranted: purchases.githubAccessGranted,
          })
          .from(purchases)
          .where(eq(purchases.stripePaymentIntentId, paymentIntentId))
          .limit(1);

        const foundPurchase = purchaseResult[0];
        if (!foundPurchase) {
          return { user: null, purchase: null };
        }

        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
            githubUsername: users.githubUsername,
          })
          .from(users)
          .where(eq(users.id, foundPurchase.userId))
          .limit(1);

        return { user: userResult[0] ?? null, purchase: foundPurchase };
      }
    );

    if (!purchase || !user) {
      return { success: false, reason: "no_matching_purchase" };
    }

    let accessRevoked = false;

    // Step 2: Revoke GitHub access (only for full refunds)
    if (isFullRefund &amp;&amp; user.githubUsername &amp;&amp; purchase.githubAccessGranted) {
      const revokeResult = await step.run(
        "revoke-github-access",
        async () =&gt; {
          return removeCollaborator(user.githubUsername!);
        }
      );
      accessRevoked = revokeResult.success;
    }

    // Step 3: Update purchase status
    await step.run("update-purchase-status", async () =&gt; {
      if (isFullRefund) {
        await db
          .update(purchases)
          .set({
            status: "refunded",
            githubAccessGranted: false,
            updatedAt: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      } else {
        await db
          .update(purchases)
          .set({
            status: "partially_refunded",
            updatedAt: new Date(),
          })
          .where(eq(purchases.id, purchase.id));
      }
    });

    // Step 4: Track refund in analytics
    await step.run("track-refund-event", async () =&gt; {
      try {
        await trackServerEvent(user.id, "refund_processed", {
          charge_id: chargeId,
          payment_intent_id: paymentIntentId,
          amount_cents: amountRefunded,
          original_amount_cents: originalAmount,
          currency,
          is_full_refund: isFullRefund,
          github_access_revoked: accessRevoked,
        });
      } catch (error) {
        console.error(`Failed to track to PostHog:`, error);
      }
    });

    // Step 5: Notify customer
    await step.run("send-customer-notification", async () =&gt; {
      if (isFullRefund) {
        await sendEmail({
          to: user.email,
          subject: `Your ${brand.name} refund has been processed`,
          template: createElement(AccessRevokedEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            currency,
          }),
        });
      } else {
        await sendEmail({
          to: user.email,
          subject: `Your ${brand.name} partial refund has been processed`,
          template: createElement(PartialRefundEmail, {
            customerEmail: user.email,
            refundAmount: amountRefunded,
            originalAmount,
            currency,
          }),
        });
      }
    });

    // Step 6: Notify admin
    await step.run("send-admin-notification", async () =&gt; {
      const adminEmail = process.env.ADMIN_EMAIL;
      if (!adminEmail) return;

      await sendEmail({
        to: adminEmail,
        subject: `\({isFullRefund ? "Full" : "Partial"} refund processed: \){user.email}`,
        template: createElement(AdminRefundNotificationEmail, {
          customerEmail: user.email,
          customerName: user.name,
          githubUsername: user.githubUsername,
          refundAmount: amountRefunded,
          originalAmount,
          currency,
          stripeChargeId: chargeId,
          accessRevoked,
          isPartialRefund: !isFullRefund,
        }),
      });
    });

    return { success: true, accessRevoked, isFullRefund, userId: user.id };
  }
);
</code></pre>
<h3 id="heading-how-full-refunds-differ-from-partial-refunds">How Full Refunds Differ from Partial Refunds</h3>
<p>The function distinguishes between the two with a simple comparison:</p>
<pre><code class="language-typescript">const isFullRefund = amountRefunded &gt;= originalAmount;
</code></pre>
<p>For a <strong>full refund</strong>, three things happen:</p>
<ol>
<li><p>GitHub access is revoked (the <code>removeCollaborator</code> call).</p>
</li>
<li><p>The purchase status is set to <code>"refunded"</code>.</p>
</li>
<li><p>The customer receives an <code>AccessRevokedEmail</code> explaining that their access has been removed.</p>
</li>
</ol>
<p>For a <strong>partial refund</strong>, the customer keeps access:</p>
<ol>
<li><p>GitHub access is <strong>not</strong> revoked.</p>
</li>
<li><p>The purchase status is set to <code>"partially_refunded"</code>.</p>
</li>
<li><p>The customer receives a <code>PartialRefundEmail</code> showing the refunded amount and the original amount.</p>
</li>
</ol>
<p>This distinction matters for your database integrity. Downstream systems (your dashboard, analytics, support tools) need accurate status values. A <code>partially_refunded</code> purchase still represents an active customer.</p>
<h3 id="heading-how-conditional-steps-work">How Conditional Steps Work</h3>
<p>The "revoke GitHub access" step only runs when three conditions are all true: it's a full refund, the user has a GitHub username, and access was previously granted.</p>
<pre><code class="language-typescript">if (isFullRefund &amp;&amp; user.githubUsername &amp;&amp; purchase.githubAccessGranted) {
  const revokeResult = await step.run("revoke-github-access", async () =&gt; {
    return removeCollaborator(user.githubUsername!);
  });
  accessRevoked = revokeResult.success;
}
</code></pre>
<p>If any of those conditions is false, the step is skipped entirely. Inngest handles this cleanly. The function continues to step 3 (update purchase status) with <code>accessRevoked</code> still set to <code>false</code>.</p>
<h2 id="heading-how-to-recover-abandoned-checkouts">How to Recover Abandoned Checkouts</h2>
<p>When a customer starts checkout but doesn't complete it, Stripe eventually expires the session (after 24 hours by default). You can listen for this event and send a recovery email.</p>
<p>The key insight is that you don't want to send the email immediately. Give the customer an hour to come back on their own.</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
export const handleCheckoutExpired = inngest.createFunction(
  {
    id: "checkout-expired",
    triggers: [{ event: "stripe/checkout.session.expired" }],
  },
  async ({ event, step }) =&gt; {
    const { customerEmail, sessionId } = event.data as {
      customerEmail: string | null;
      sessionId: string;
    };

    if (!customerEmail) {
      return { success: false, reason: "no_email" };
    }

    // Wait 1 hour before sending recovery email
    await step.sleep("wait-before-recovery-email", "1h");

    // Send abandoned cart email
    await step.run("send-abandoned-cart-email", async () =&gt; {
      const baseUrl =
        process.env.BETTER_AUTH_URL ?? "https://your-app.com";
      const checkoutUrl = `${baseUrl}/pricing`;

      await sendEmail({
        to: customerEmail,
        subject: `Your ${brand.name} checkout is waiting`,
        template: createElement(AbandonedCartEmail, {
          customerEmail,
          checkoutUrl,
        }),
      });
    });

    // Track the recovery attempt
    await step.run("track-abandoned-cart", async () =&gt; {
      try {
        await trackServerEvent("anonymous", "abandoned_cart_email_sent", {
          customer_email: customerEmail,
          session_id: sessionId,
        });
      } catch (error) {
        console.error(`Failed to track to PostHog:`, error);
      }
    });

    return { success: true, customerEmail };
  }
);
</code></pre>
<p>The <code>step.sleep("wait-before-recovery-email", "1h")</code> line pauses the function for one hour without consuming compute resources. Inngest schedules the function to resume after the delay. No cron jobs, no Redis queues, no <code>setTimeout</code> that gets lost when your server restarts.</p>
<p>There is a guard at the top of the function. If the checkout session has no customer email (the customer closed the page before entering their email), the function returns early. You can't send a recovery email without an address.</p>
<p>You could extend this pattern with a second sleep and follow-up email three days later. You could also check if the customer has since completed a purchase (by querying the database in a <code>step.run()</code>) and skip the email if they have.</p>
<h3 id="heading-why-one-hour-is-the-right-delay">Why One Hour Is the Right Delay</h3>
<p>Sending the recovery email immediately after checkout expiration feels aggressive. The customer might still be comparing options, waiting for payday, or just distracted. An immediate email says "we noticed you left," which feels surveillance-like.</p>
<p>Waiting 24 hours is too long. The customer has moved on. They have forgotten your product or found an alternative.</p>
<p>One hour is the sweet spot I found through testing. The customer's intent is still fresh, and the email feels helpful rather than pushy.</p>
<p>Your mileage may vary. The delay is configurable: change <code>"1h"</code> to <code>"30m"</code> or <code>"3h"</code> and redeploy.</p>
<h3 id="heading-why-this-is-better-than-a-cron-job">Why This Is Better Than a Cron Job</h3>
<p>Without durable execution, abandoned cart recovery typically works like this: a cron job runs every hour, queries the database for expired sessions that haven't been recovered yet, sends emails to each one, and marks them as recovered.</p>
<p>This approach has several problems. You need a <code>recovered_at</code> column to avoid sending duplicate emails. You need to handle the case where the cron job crashes halfway through the batch, and you need to tune the cron interval carefully.</p>
<p>The <code>step.sleep()</code> approach eliminates all of this. Each expired session gets its own function instance with its own timer. There's no batch processing, no database flag, and no duplicate risk.</p>
<h2 id="heading-how-to-send-transactional-emails-with-react-email">How to Send Transactional Emails with React Email</h2>
<p>Every email in the payment flow is a React component rendered to HTML and sent via Resend. This gives you type-safe templates with props, component reuse, and the ability to preview emails in your browser during development.</p>
<h3 id="heading-how-to-set-up-the-email-client">How to Set Up the Email Client</h3>
<p>The email client wraps Resend with a simple <code>sendEmail</code> function:</p>
<pre><code class="language-typescript">// src/lib/email/index.ts
import { render } from "@react-email/components";
import type { ReactElement } from "react";
import { Resend } from "resend";

import { brand } from "@/lib/brand";

let resendClient: Resend | null = null;

function getResend(): Resend {
  if (!resendClient) {
    const apiKey = process.env.RESEND_API_KEY;
    if (!apiKey) {
      throw new Error("RESEND_API_KEY is not set");
    }
    resendClient = new Resend(apiKey);
  }
  return resendClient;
}

interface SendEmailOptions {
  to: string | string[];
  subject: string;
  template: ReactElement;
  from?: string;
  replyTo?: string;
}

export async function sendEmail({
  to,
  subject,
  template,
  from = process.env.EMAIL_FROM ?? brand.emails.from,
  replyTo,
}: SendEmailOptions) {
  const resend = getResend();
  const html = await render(template);

  return resend.emails.send({
    from,
    to,
    subject,
    html,
    replyTo,
  });
}
</code></pre>
<p>The <code>render()</code> function from <code>@react-email/components</code> converts a React element into an HTML string. This HTML is what Resend delivers to the customer's inbox.</p>
<p>The <code>from</code> address defaults to your brand's email configuration. You need a verified domain in Resend for this to work. During development, Resend's free tier lets you send to your own email address without domain verification.</p>
<h3 id="heading-how-to-build-a-purchase-confirmation-template">How to Build a Purchase Confirmation Template</h3>
<p>Here's the real purchase confirmation email template:</p>
<pre><code class="language-tsx">// src/lib/email/emails/purchase-confirmation.tsx
import {
  Body,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Link,
  Preview,
  Section,
  Text,
} from "@react-email/components";

import { brand } from "@/lib/brand";

interface PurchaseConfirmationEmailProps {
  amount: number;
  currency: string;
  customerEmail: string;
}

const colors = {
  primary: "#d97757",
  background: "#faf9f5",
  foreground: "#30302e",
  muted: "#6b6860",
  border: "#e5e4df",
  card: "#ffffff",
  success: "#16a34a",
  successLight: "#f0fdf4",
};

export default function PurchaseConfirmationEmail({
  amount,
  currency,
  customerEmail,
}: PurchaseConfirmationEmailProps) {
  const formattedAmount = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: currency.toUpperCase(),
  }).format(amount / 100);

  return (
    &lt;Html&gt;
      &lt;Head /&gt;
      &lt;Preview&gt;Your {brand.name} purchase is confirmed!&lt;/Preview&gt;
      &lt;Body style={main}&gt;
        &lt;Container style={container}&gt;
          &lt;Section style={header}&gt;
            &lt;Text style={logoText}&gt;{brand.name}&lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Section style={successBadge}&gt;
            &lt;Text style={successText}&gt;Payment Successful&lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Heading style={h1}&gt;Thank you for your purchase!&lt;/Heading&gt;

          &lt;Text style={text}&gt;
            Your payment has been processed successfully. We are now setting
            up your GitHub repository access. You will receive another email
            shortly with your access link.
          &lt;/Text&gt;

          &lt;Section style={detailsBox}&gt;
            &lt;Text style={detailsTitle}&gt;Order Details&lt;/Text&gt;

            &lt;Section style={detailRow}&gt;
              &lt;Text style={detailLabel}&gt;Product&lt;/Text&gt;
              &lt;Text style={detailValue}&gt;{brand.name}&lt;/Text&gt;
            &lt;/Section&gt;

            &lt;Section style={detailRow}&gt;
              &lt;Text style={detailLabel}&gt;Amount&lt;/Text&gt;
              &lt;Text style={detailValue}&gt;{formattedAmount}&lt;/Text&gt;
            &lt;/Section&gt;

            &lt;Section style={detailRow}&gt;
              &lt;Text style={detailLabel}&gt;Email&lt;/Text&gt;
              &lt;Text style={detailValue}&gt;{customerEmail}&lt;/Text&gt;
            &lt;/Section&gt;
          &lt;/Section&gt;

          &lt;Text style={text}&gt;
            This is a one-time purchase. No recurring charges will be made.
          &lt;/Text&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Text style={footer}&gt;
            Questions about your purchase? Reply to this email or reach
            out at{" "}
            &lt;Link
              href={`mailto:${brand.emails.support}`}
              style={link}
            &gt;
              {brand.emails.support}
            &lt;/Link&gt;
          &lt;/Text&gt;
        &lt;/Container&gt;
      &lt;/Body&gt;
    &lt;/Html&gt;
  );
}

PurchaseConfirmationEmail.PreviewProps = {
  amount: 9900,
  currency: "usd",
  customerEmail: "customer@example.com",
} satisfies PurchaseConfirmationEmailProps;
</code></pre>
<p>A few things to note about this template.</p>
<ul>
<li><p><strong>Currency formatting happens in the template:</strong> The <code>amount</code> prop is in cents (the same format stored in your database and returned by Stripe). The <code>Intl.NumberFormat</code> call converts it to a human-readable string like "$99.00" and keeps currency formatting logic in one place.</p>
</li>
<li><p><strong>The</strong> <code>PreviewProps</code> <strong>object is for development.</strong> React Email uses these props to render a preview in the browser. The <code>satisfies</code> keyword ensures the preview props match the component's interface.</p>
</li>
<li><p><strong>All styles are inline objects.</strong> Email clients strip <code>&lt;style&gt;</code> tags and ignore most CSS. Inline styles are the only reliable way to style emails across Gmail, Outlook, Apple Mail, and every other client.</p>
</li>
</ul>
<h3 id="heading-how-to-build-a-repo-access-template">How to Build a Repo Access Template</h3>
<p>The repo access email is sent after the GitHub invitation succeeds:</p>
<pre><code class="language-tsx">// src/lib/email/emails/repo-access-granted.tsx
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Link,
  Preview,
  Section,
  Text,
} from "@react-email/components";

import { brand } from "@/lib/brand";

interface RepoAccessGrantedEmailProps {
  repoUrl: string;
}

export default function RepoAccessGrantedEmail({
  repoUrl,
}: RepoAccessGrantedEmailProps) {
  return (
    &lt;Html&gt;
      &lt;Head /&gt;
      &lt;Preview&gt;Your {brand.name} repository access is ready!&lt;/Preview&gt;
      &lt;Body style={main}&gt;
        &lt;Container style={container}&gt;
          &lt;Section style={header}&gt;
            &lt;Text style={logoText}&gt;{brand.name}&lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Heading style={h1}&gt;You are in!&lt;/Heading&gt;

          &lt;Text style={text}&gt;
            Your GitHub repository access has been granted. You now have
            full access to the {brand.name} codebase.
          &lt;/Text&gt;

          &lt;Section style={buttonContainer}&gt;
            &lt;Button style={button} href={repoUrl}&gt;
              Open Repository
            &lt;/Button&gt;
          &lt;/Section&gt;

          &lt;Section style={infoBox}&gt;
            &lt;Text style={infoTitle}&gt;Quick Start&lt;/Text&gt;
            &lt;Text style={infoText}&gt;
              &lt;strong&gt;1.&lt;/strong&gt; Clone the repository to your machine
            &lt;/Text&gt;
            &lt;Text style={infoText}&gt;
              &lt;strong&gt;2.&lt;/strong&gt; Run{" "}
              &lt;code style={codeStyle}&gt;bun install&lt;/code&gt; to install
              dependencies
            &lt;/Text&gt;
            &lt;Text style={infoText}&gt;
              &lt;strong&gt;3.&lt;/strong&gt; Follow the README for environment setup
            &lt;/Text&gt;
            &lt;Text style={infoText}&gt;
              &lt;strong&gt;4.&lt;/strong&gt; Run{" "}
              &lt;code style={codeStyle}&gt;bun dev&lt;/code&gt; to start building
            &lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Text style={footer}&gt;
            Need help? Reply to this email or reach out at{" "}
            &lt;Link
              href={`mailto:${brand.emails.support}`}
              style={link}
            &gt;
              {brand.emails.support}
            &lt;/Link&gt;
          &lt;/Text&gt;
        &lt;/Container&gt;
      &lt;/Body&gt;
    &lt;/Html&gt;
  );
}
</code></pre>
<p>This template includes a <code>&lt;Button&gt;</code> component that links directly to the GitHub repository. The quick start section gives the customer immediate next steps so they aren't left wondering what to do after gaining access.</p>
<h3 id="heading-how-to-build-an-abandoned-cart-template">How to Build an Abandoned Cart Template</h3>
<p>The abandoned cart email brings the customer back to your pricing page:</p>
<pre><code class="language-tsx">// src/lib/email/emails/abandoned-cart.tsx
import {
  Body,
  Button,
  Container,
  Head,
  Heading,
  Hr,
  Html,
  Preview,
  Section,
  Text,
} from "@react-email/components";

import { brand } from "@/lib/brand";

interface AbandonedCartEmailProps {
  customerEmail: string;
  checkoutUrl: string;
}

export default function AbandonedCartEmail({
  customerEmail,
  checkoutUrl,
}: AbandonedCartEmailProps) {
  return (
    &lt;Html&gt;
      &lt;Head /&gt;
      &lt;Preview&gt;Your {brand.name} checkout is waiting for you&lt;/Preview&gt;
      &lt;Body style={main}&gt;
        &lt;Container style={container}&gt;
          &lt;Section style={header}&gt;
            &lt;Text style={logoText}&gt;{brand.name}&lt;/Text&gt;
          &lt;/Section&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Heading style={h1}&gt;You left something behind&lt;/Heading&gt;

          &lt;Text style={text}&gt;
            We noticed you started a checkout but did not complete your
            purchase. No worries. Your cart is still waiting for you.
          &lt;/Text&gt;

          &lt;Text style={text}&gt;
            {brand.name} gives you everything you need to ship your
            startup this weekend: authentication, payments, email,
            background jobs, and more. All wired together and ready
            to go.
          &lt;/Text&gt;

          &lt;Section style={buttonContainer}&gt;
            &lt;Button style={button} href={checkoutUrl}&gt;
              Complete Your Purchase
            &lt;/Button&gt;
          &lt;/Section&gt;

          &lt;Text style={textSmall}&gt;
            If you ran into any issues during checkout or have questions
            about {brand.name}, just reply to this email. I read every
            message personally.
          &lt;/Text&gt;

          &lt;Hr style={divider} /&gt;

          &lt;Text style={footer}&gt;
            This email was sent to {customerEmail} because you started
            a checkout on {brand.name}. If this was not you, you can
            safely ignore this email.
          &lt;/Text&gt;
        &lt;/Container&gt;
      &lt;/Body&gt;
    &lt;/Html&gt;
  );
}
</code></pre>
<p>The tone matters here. "You left something behind" is friendly, not pushy. The email explains the product's value briefly, includes a single clear call to action, and the footer explains why they received the email.</p>
<h3 id="heading-how-templates-integrate-with-durable-steps">How Templates Integrate with Durable Steps</h3>
<p>Every email template is invoked via <code>createElement</code> inside a <code>step.run()</code> block:</p>
<pre><code class="language-typescript">await step.run("send-purchase-confirmation", async () =&gt; {
  await sendEmail({
    to: user.email,
    subject: `Your ${brand.name} purchase is confirmed!`,
    template: createElement(PurchaseConfirmationEmail, {
      amount: purchase.amount,
      currency: purchase.currency,
      customerEmail: user.email,
    }),
  });
});
</code></pre>
<p>The <code>createElement</code> call creates a React element from the template component with the given props. The <code>sendEmail</code> function renders it to HTML via React Email's <code>render()</code> and sends it through Resend.</p>
<p>Because this is inside a <code>step.run()</code>, the email send is checkpointed. If Resend is down and the step fails, it retries on its own without re-running previous steps. The customer never gets a duplicate email.</p>
<h2 id="heading-how-to-test-the-complete-flow-locally">How to Test the Complete Flow Locally</h2>
<p>Testing the complete payment lifecycle locally requires three things running simultaneously: your application, the Stripe CLI forwarding webhook events, and the Inngest dev server processing background jobs.</p>
<h3 id="heading-step-1-start-the-stripe-cli">Step 1: Start the Stripe CLI</h3>
<p>Install the Stripe CLI and log in:</p>
<pre><code class="language-bash"># macOS
brew install stripe/stripe-cli/stripe

# Authenticate
stripe login
</code></pre>
<p>Forward webhook events to your local server:</p>
<pre><code class="language-bash">stripe listen --forward-to localhost:3000/api/payments/webhook
</code></pre>
<p>The CLI prints a webhook signing secret starting with <code>whsec_</code>. Copy this to your <code>.env</code> as <code>STRIPE_WEBHOOK_SECRET</code>.</p>
<h3 id="heading-step-2-start-the-inngest-dev-server">Step 2: Start the Inngest Dev Server</h3>
<p>The Inngest dev server gives you real-time visibility into every function execution, every step, and every retry:</p>
<pre><code class="language-bash">npx inngest-cli@latest dev -u http://localhost:3000/api/inngest
</code></pre>
<p>Open <code>http://localhost:8288</code> in your browser. This is the Inngest dashboard where you'll watch your durable functions execute step by step.</p>
<h3 id="heading-step-3-start-your-application">Step 3: Start Your Application</h3>
<pre><code class="language-bash">bun run dev
</code></pre>
<p>Your application should now be running on <code>http://localhost:3000</code>.</p>
<h3 id="heading-step-4-test-the-purchase-flow">Step 4: Test the Purchase Flow</h3>
<ol>
<li><p>Go to your pricing page and click the checkout button.</p>
</li>
<li><p>Use Stripe's test card number <code>4242 4242 4242 4242</code> with any future expiration date and any CVC.</p>
</li>
<li><p>Complete the checkout. Stripe redirects you to your success URL.</p>
</li>
<li><p>Your frontend calls the <code>/api/purchases/claim</code> endpoint with the session ID.</p>
</li>
<li><p>Watch the Inngest dashboard. You should see the <code>purchase-completed</code> function trigger and each step execute in sequence.</p>
</li>
</ol>
<p>In the Inngest dashboard, you will see:</p>
<ul>
<li><p><strong>Step 1:</strong> "lookup-user-and-purchase" completes with the user and purchase data.</p>
</li>
<li><p><strong>Step 2:</strong> "track-purchase-to-posthog" completes (or logs a warning if PostHog isn't configured).</p>
</li>
<li><p><strong>Step 3:</strong> "send-purchase-confirmation" completes. Check your email.</p>
</li>
<li><p><strong>Step 4:</strong> "send-admin-notification" completes (if <code>ADMIN_EMAIL</code> is set).</p>
</li>
<li><p><strong>Steps 5-9:</strong> Run if the user has a GitHub username linked.</p>
</li>
</ul>
<h3 id="heading-step-5-test-a-refund">Step 5: Test a Refund</h3>
<p>Trigger a refund through the Stripe CLI:</p>
<pre><code class="language-bash">stripe trigger charge.refunded
</code></pre>
<p>Or go to the Stripe dashboard, find the test payment, and issue a refund manually. The Stripe CLI will forward the <code>charge.refunded</code> webhook to your local server.</p>
<p>In the Inngest dashboard, you'll see the <code>refund-processed</code> function trigger with its own set of steps: lookup, conditional access revocation, status update, analytics tracking, and email notifications.</p>
<h3 id="heading-step-6-test-abandoned-cart-recovery">Step 6: Test Abandoned Cart Recovery</h3>
<p>Trigger a checkout expiration:</p>
<pre><code class="language-bash">stripe trigger checkout.session.expired
</code></pre>
<p>The <code>checkout-expired</code> function will appear in the Inngest dashboard. You'll see the 1-hour sleep step. In the dev server, you can fast-forward through sleeps by clicking the "Skip" button in the dashboard. This lets you test the delayed email without actually waiting an hour.</p>
<h3 id="heading-how-to-simulate-step-failures">How to Simulate Step Failures</h3>
<p>To test the retry behavior, temporarily throw an error in one of your steps:</p>
<pre><code class="language-typescript">const collaboratorResult = await step.run(
  "add-github-collaborator",
  async () =&gt; {
    throw new Error("Simulated GitHub API failure");
  }
);
</code></pre>
<p>In the Inngest dashboard, you'll see:</p>
<ul>
<li><p>Steps 1 through 4 succeed and their results are cached.</p>
</li>
<li><p>Step 5 fails and is retried with exponential backoff.</p>
</li>
<li><p>Steps 6 through 9 remain pending.</p>
</li>
</ul>
<p>Remove the thrown error, and on the next retry, step 5 succeeds. Steps 6 through 9 execute, while steps 1 through 4 aren't re-executed. This is the checkpointing behavior that makes durable execution reliable.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building a complete SaaS payment flow is more than integrating Stripe Checkout. It's the entire lifecycle from "Buy" button to "Welcome" email, including the parts that happen when things go wrong.</p>
<p>Here's what you built in this tutorial:</p>
<ul>
<li><p>A <strong>database schema</strong> that tracks purchases through every state: completed, partially refunded, and fully refunded.</p>
</li>
<li><p>A <strong>Stripe product and price seed script</strong> that creates your catalog programmatically.</p>
</li>
<li><p>A <strong>checkout flow</strong> with session creation, payment verification, and idempotent purchase claiming.</p>
</li>
<li><p>A <strong>thin webhook handler</strong> that validates signatures and routes events to background jobs.</p>
</li>
<li><p>A <strong>9-step durable purchase function</strong> where each step is independently checkpointed and retried.</p>
</li>
<li><p>A <strong>refund handler</strong> that distinguishes between full and partial refunds, revoking access only when appropriate.</p>
</li>
<li><p>An <strong>abandoned cart recovery flow</strong> that waits an hour before sending a friendly recovery email.</p>
</li>
<li><p><strong>Three transactional email templates</strong> built with React Email: purchase confirmation, repo access granted, and abandoned cart.</p>
</li>
<li><p>A <strong>local testing setup</strong> with Stripe CLI, Inngest dev server, and step-by-step observability.</p>
</li>
</ul>
<p>The most important pattern is the separation between receiving and processing. Your API endpoints and webhook handlers should be thin: validate, record, enqueue, return. All the complex multi-step work happens in durable background functions where failures are isolated and retried at the step level.</p>
<p>This pattern scales. Add a new step to the purchase flow, and it gets the same checkpointing and retry behavior. Add a new webhook event, and you route it to a new durable function.</p>
<p>Your requirements may differ. You might sell subscriptions instead of one-time purchases, or provision API keys instead of GitHub access. The specific steps change, but the architecture stays the same.</p>
<p>If you want to start with all of these patterns already wired together in a production-ready codebase, <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=saas-payment-flow-stripe-webhooks-email">Eden Stack</a> includes the complete payment flow described in this article, along with 30+ additional production-tested patterns for authentication, email, analytics, background jobs, and more.</p>
<p><em>Magnus Rødseth builds AI-native applications and is the creator of</em> <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=saas-payment-flow-stripe-webhooks-email"><em>Eden Stack</em></a><em>, a production-ready starter kit with 30+ Claude skills encoding production patterns for AI-native SaaS development.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build High-Ranking SEO Landing Page ]]>
                </title>
                <description>
                    <![CDATA[ New products are dropping daily, and smart sellers are quietly stacking profits. If you understand how SEO landing pages actually work today, you’re not guessing. Instead, you're building assets that  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-high-ranking-seo-landing-page/</link>
                <guid isPermaLink="false">69fa212aa386d7f121b568d8</guid>
                
                    <category>
                        <![CDATA[ SEO ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Casmir Onyekani ]]>
                </dc:creator>
                <pubDate>Tue, 05 May 2026 16:56:10 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/11f1d667-12a7-4097-8583-eb1a8f87bf02.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>New products are dropping daily, and smart sellers are quietly stacking profits.</p>
<p>If you understand how SEO landing pages actually work today, you’re not guessing. Instead, you're building assets that pull traffic and convert.</p>
<p>This guide walks you through how I researched, structured, built, and deployed a real SEO landing page tailored for affiliate marketers.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-keyword-research">Keyword Research</a></p>
<ul>
<li><p><a href="#heading-supporting-keywords">Supporting Keywords</a></p>
</li>
<li><p><a href="#heading-how-did-i-get-those-keywords">How Did I Get Those Keywords?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-to-do-after-keyword-research">What To Do After Keyword Research</a></p>
<ul>
<li><p><a href="#heading-lock-your-page-intent-critical">Lock Your Page Intent (Critical)</a></p>
</li>
<li><p><a href="#heading-group-your-keywords-clustering">Group Your Keywords (Clustering)</a></p>
</li>
<li><p><a href="#heading-analyze-the-serp-your-real-competitors">Analyze the SERP (Your Real Competitors)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-create-your-seo-landing-page-blueprint">Create Your SEO Landing Page Blueprint</a></p>
<ul>
<li><p><a href="#heading-above-the-fold-conversion-zone">Above the Fold (Conversion Zone)</a></p>
</li>
<li><p><a href="#heading-main-content-ranking-and-conversion-zone">Main Content (Ranking and Conversion Zone)</a></p>
</li>
<li><p><a href="#heading-scalability-thinking">Scalability Thinking</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-i-built-the-project-development-process">How I Built the Project (Development Process)</a></p>
</li>
<li><p><a href="#heading-on-page-seo-setup">On-Page SEO Setup</a></p>
<ul>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-seo-metadata-setup">SEO + Metadata Setup</a></p>
</li>
<li><p><a href="#heading-structured-data-schema-markup">Structured Data (Schema Markup)</a></p>
</li>
<li><p><a href="#heading-hero-section-above-the-fold-seo">Hero Section (Above-the-Fold SEO)</a></p>
</li>
<li><p><a href="#heading-value-section-keyword-reinforcement">Value Section (Keyword Reinforcement)</a></p>
</li>
<li><p><a href="#heading-benefits-grid-scannable-seo-content">Benefits Grid (Scannable SEO Content)</a></p>
</li>
<li><p><a href="#heading-product-section-conversion-affiliate-seo">Product Section (Conversion + Affiliate SEO)</a></p>
</li>
<li><p><a href="#heading-comparison-table-high-intent-seo-content">Comparison Table (High-Intent SEO Content)</a></p>
</li>
<li><p><a href="#heading-review-section">Review Section</a></p>
</li>
<li><p><a href="#heading-faq-section-search-expansion">FAQ Section (Search Expansion)</a></p>
</li>
<li><p><a href="#heading-cta-section-conversion-signal">CTA Section (Conversion Signal)</a></p>
</li>
<li><p><a href="#heading-footer">Footer</a></p>
</li>
<li><p><a href="#heading-javascript-ux-enhancements">JavaScript UX Enhancements</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-deployment-using-netlify">Deployment (Using Netlify)</a></p>
<ul>
<li><p><a href="#heading-why-netlify">Why Netlify?</a></p>
</li>
<li><p><a href="#heading-step-1-push-your-project-to-github">Step 1 — Push Your Project to GitHub</a></p>
</li>
<li><p><a href="#heading-enable-https">Enable HTTPS</a></p>
</li>
<li><p><a href="#heading-publish-and-index">Publish and Index</a></p>
</li>
<li><p><a href="#heading-post-publish-this-is-where-ranking-happens">Post-Publish (This Is Where Ranking Happens)</a></p>
</li>
<li><p><a href="#heading-turn-this-into-a-system-advanced-move">Turn This Into a System (Advanced Move)</a></p>
</li>
<li><p><a href="#heading-simple-reality-check">Simple Reality Check</a></p>
</li>
<li><p><a href="#heading-final-result">Final Result</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following this guide, you should have:</p>
<ul>
<li><p>Basic understanding of how websites work (HTML, CSS and JavaScript basics is helpful)</p>
</li>
<li><p>A GitHub account (for deployment)</p>
</li>
<li><p>A Netlify account (free)</p>
</li>
<li><p>Basic understanding of SEO (keywords, search intent)</p>
</li>
<li><p>A niche or product idea to build your landing page around</p>
</li>
</ul>
<p>Optional but helpful:</p>
<ul>
<li>Familiarity with tools like Google Keyword Planner, Ahrefs, or Semrush</li>
</ul>
<h2 id="heading-keyword-research">Keyword Research</h2>
<p>First, get your primary keyword. The primary keyword for the purpose of this article is "<strong>eco-friendly running shoes</strong>".</p>
<h3 id="heading-supporting-keywords">Supporting Keywords:</h3>
<ul>
<li><p>eco friendly running shoes for men</p>
</li>
<li><p>sustainable running shoes brands</p>
</li>
<li><p>biodegradable running shoes</p>
</li>
<li><p>vegan running shoes</p>
</li>
<li><p>best recycled material sneakers</p>
</li>
<li><p>eco running shoes review</p>
</li>
<li><p>affordable eco-friendly sneakers</p>
</li>
<li><p>lightweight sustainable running shoes</p>
</li>
</ul>
<h4 id="heading-how-did-i-get-those-keywords">How Did I Get Those Keywords?</h4>
<p>Well, by using tool-based research. I simulated the process with <a href="https://business.google.com/en-all/google-ads/">Google Keyword Planner</a>. There are also other tools like Semrush and Ahrefs you can use for this purpose.</p>
<h4 id="heading-process">Process</h4>
<ul>
<li><p>Entered: best <em>eco-friendly running shoes</em></p>
</li>
<li><p>Filtered for:</p>
<ul>
<li><p>Buyer intent (words like “best”, “buy”, “review”)</p>
</li>
<li><p>Medium/low competition</p>
</li>
<li><p>Long-tail phrases</p>
</li>
</ul>
</li>
</ul>
<h4 id="heading-serp-intent-research">SERP Intent Research</h4>
<p>Next, I opened my browser and searched: best eco-friendly running shoes</p>
<p>Then I analyzed:</p>
<ul>
<li><p>Are results blog posts or product pages?</p>
</li>
<li><p>Do they compare products?</p>
</li>
<li><p>Are there buying guides?</p>
</li>
</ul>
<p>I found a variety of list-style articles (listicles), product comparisons pages, affiliate-style landing pages, and strong CTAs like "Buy now” or “Check price”.</p>
<p>This tells us users want curated recommendations plus a clear path to purchase.</p>
<h4 id="heading-keyword-selection-logic">Keyword Selection Logic</h4>
<p>I picked the primary keyword because:</p>
<ul>
<li><p>It shows clear buying intent</p>
</li>
<li><p>It’s long-tail (easier to rank)</p>
</li>
<li><p>It matches a conversion-focused landing page</p>
</li>
</ul>
<h2 id="heading-what-to-do-after-keyword-research">What To Do After Keyword Research</h2>
<p>Once you have your keywords, don’t jump into writing yet. Do this:</p>
<h3 id="heading-lock-your-page-intent-critical">Lock Your Page Intent (Critical)</h3>
<p>Before writing anything, decide your page goal: Is it to sell or educate?</p>
<p>The four types of search intent are commercial, transactional, informational, and navigational.</p>
<p>For this keyword – <em>best eco-friendly running shoes</em> – the <strong>primary Intent</strong> is transactional (buying) while the <strong>secondary</strong> <strong>intent</strong> is informational.</p>
<p>So your page needs to help users choose the right product and guide them toward making a purchase.</p>
<p>If your intent is unclear, your page will struggle to rank.</p>
<h3 id="heading-group-your-keywords-clustering">Group Your Keywords (Clustering)</h3>
<p>Instead of treating keywords separately, organize them into groups.</p>
<p>Let's look at an example:</p>
<p><strong>Primary Keyword</strong></p>
<ul>
<li>best eco-friendly running shoes</li>
</ul>
<p><strong>Cluster 1 – Buyer Intent</strong></p>
<ul>
<li><p>eco friendly running shoes for men</p>
</li>
<li><p>best sustainable running shoes</p>
</li>
<li><p>affordable eco-friendly sneakers</p>
</li>
</ul>
<p><strong>Cluster 2 – Informational</strong></p>
<ul>
<li><p>what are eco-friendly running shoes</p>
</li>
<li><p>benefits of sustainable shoes</p>
</li>
<li><p>how eco shoes are made</p>
</li>
</ul>
<p>This matters because Google ranks pages based on topics, not just individual keywords.</p>
<h3 id="heading-analyze-the-serp-your-real-competitors">Analyze the SERP (Your Real Competitors)</h3>
<p>Search your keyword again and study the top 3 – 5 results.</p>
<p>Look at page structure, content length, and the sections included.</p>
<p>You should also check for patterns:</p>
<ul>
<li><p>Do they list multiple products?</p>
</li>
<li><p>Do they include comparison tables?</p>
</li>
<li><p>Are there FAQs?</p>
</li>
<li><p>Do they use images or videos?</p>
</li>
</ul>
<p>Your goal is not to copy but to build something better structured and more useful.</p>
<h2 id="heading-create-your-seo-landing-page-blueprint">Create Your SEO Landing Page Blueprint</h2>
<p>Now turn your research into a clear structure.</p>
<p>Your page should look like this:</p>
<h3 id="heading-above-the-fold-conversion-zone">Above the Fold (Conversion Zone)</h3>
<p>This is what users see first.</p>
<ul>
<li><p>H1: Best Eco-Friendly Running Shoes</p>
</li>
<li><p>Short sub-headline (value-focused)</p>
</li>
<li><p>Strong CTA (<em>for example, Shop Now, View Top Picks</em>)</p>
</li>
</ul>
<h3 id="heading-main-content-ranking-and-conversion-zone">Main Content (Ranking and Conversion Zone)</h3>
<p>This is where SEO and conversions happen together.</p>
<p>You should include:</p>
<ol>
<li><p><strong>Top Products Section</strong>: List at least 5 recommended shoes</p>
</li>
<li><p><strong>Benefits of Eco-Friendly Shoes</strong>: Educate users briefly</p>
</li>
<li><p><strong>Comparison Table</strong>: Help users quickly decide</p>
</li>
<li><p><strong>Product Reviews / Social Proof</strong>: Build trust</p>
</li>
<li><p><strong>FAQ Section</strong>: Answer common questions and capture extra search traffic.</p>
</li>
</ol>
<h3 id="heading-scalability-thinking">Scalability Thinking</h3>
<p>If your goal is to build multiple SEO pages, you need a system — not random pages. I designed this landing page to be scalable.</p>
<p>In practice, scalability means a few different things.</p>
<p>First, you'll need to have a reusable layout. Instead of designing every page from scratch, create one high-converting template (headline, product list, comparison table, FAQs). Then reuse it across different keywords.</p>
<p>Second, you'll need to understand content swapping. This means you only change the keyword, product list, images, and supporting content.</p>
<p>For example:</p>
<ul>
<li><p>Page 1: Best eco-friendly running shoes</p>
</li>
<li><p>Page 2: Best vegan running shoes</p>
</li>
<li><p>Page 3: Best sustainable gym wear</p>
</li>
</ul>
<p>You'll also want to make sure your site has a consistent structure as this results in faster ranking. Google understands your site better when pages follow a clear pattern.</p>
<p>You should also understand the advantages of internal linking. When you connect these pages together, you build topical authority, which improves rankings across all pages.</p>
<p>The goal is simple: build once, scale many times.</p>
<h2 id="heading-how-i-built-the-project-development-process">How I Built the Project (Development Process)</h2>
<p>This <a href="https://ecorunningshoes.netlify.app/">sample landing page</a> is designed for affiliate marketers.</p>
<p>Note that the brand names, values, images, and prices used on this page are fictitious and are for demonstration purposes only.</p>
<p>To build this page, I used HTML, CSS, and JavaScript.</p>
<p>My focus areas were:</p>
<ul>
<li><p>That it had a clean and structured layout</p>
</li>
<li><p>That is was fast loading and scored well on performance</p>
</li>
<li><p>That it had a mobile-friendly design</p>
</li>
</ul>
<h2 id="heading-on-page-seo-setup">On-Page SEO Setup</h2>
<p>Here’s a real example of how I built this SEO landing page:</p>
<h3 id="heading-project-structure">Project Structure</h3>
<pre><code class="language-markdown">ECO-PREMIUM/
├── index.html
├── styles.css
├── script.js
├── README.md
└── assets/
    └── shoes/
        ├── allbirds.jpg
        ├── reebok.jpg
        ├── brooks.jpg
        ├── veja.jpg
        ├── adidas.jpg
        ├── nike.jpg
        ├── hero-bg.jpg
</code></pre>
<h3 id="heading-seo-metadata-setup">SEO + Metadata Setup</h3>
<p>This is where SEO starts. Before design, I made sure Google understands the page instantly.</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;Best Eco-Friendly Running Shoes (2026 Guide)&lt;/title&gt;

&lt;meta name="description" content="Discover the best eco-friendly running shoes for men and women. Compare sustainable running shoes brands, reviews, and find affordable eco sneakers."&gt;

&lt;link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"&gt;

&lt;link rel="stylesheet" href="styles.css"&gt;

&lt;link rel="preload" as="image" href="assets/shoes/allbirds.jpg"&gt;
</code></pre>
<h4 id="heading-why-this-matters-seo-strategy">Why This Matters (SEO Strategy)</h4>
<ul>
<li><p>The <strong>viewport tag</strong> makes sure the page looks good on mobile, which is important for rankings.</p>
</li>
<li><p>The <strong>title</strong> combines the main keyword with a year like “2026” to keep it fresh and relevant in search.</p>
</li>
<li><p>The <strong>meta description</strong> briefly explains what users will get and encourages clicks.</p>
</li>
<li><p>The <strong>Font Awesome stylesheet</strong> adds scalable icons for better UI and trust signals.</p>
</li>
<li><p>The <strong>styles.css file</strong> controls the site’s design, ensuring a clean, responsive layout that supports user experience and engagement.</p>
</li>
<li><p>And <strong>preloading key images</strong> helps the page load faster, improving performance and SEO.</p>
</li>
</ul>
<h3 id="heading-structured-data-schema-markup">Structured Data (Schema Markup)</h3>
<pre><code class="language-html">&lt;script type="application/ld+json"&gt;
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What are eco-friendly running shoes?",
"acceptedAnswer": {"@type": "Answer","text": "They are shoes made from sustainable materials."}
}
]
}
&lt;/script&gt;
</code></pre>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<ul>
<li>It enables rich snippets in Google, improves click-through rates, and strengthens topical authority.</li>
</ul>
<h3 id="heading-hero-section-above-the-fold-seo">Hero Section (Above-the-Fold SEO)</h3>
<p>This is the most important section for both users and search engines.</p>
<pre><code class="language-html">&lt;header class="hero"&gt;
  &lt;div class="hero-overlay"&gt;&lt;/div&gt;

  &lt;div class="hero-content"&gt;
    &lt;h1&gt;Best Eco-Friendly Running Shoes&lt;/h1&gt;
    &lt;p&gt;High-performance. Sustainable. Built for runners who care.&lt;/p&gt;
    &lt;a href="#products" class="btn"&gt;Explore Top Picks&lt;/a&gt;
  &lt;/div&gt;
&lt;/header&gt;
</code></pre>
<p>The styling:</p>
<pre><code class="language-css">.hero {
  background:url('assets/shoes/hero-bg.jpg') center/cover no-repeat;
  min-height:90vh;
  display:flex;
  justify-content:center;
  align-items:center;
  text-align:center;
}

.hero-overlay {
  position:absolute;
  inset:0;
  background:rgba(0,0,0,0.6);
}
</code></pre>
<h5 id="heading-the-ui-view">The UI view:</h5>
<img src="https://cdn.hashnode.com/uploads/covers/647d7b660f441a49aa878a9e/1b58dc21-d469-4ec0-b7bd-8fff6437d4ef.png" alt="1b58dc21-d469-4ec0-b7bd-8fff6437d4ef" style="display:block;margin:0 auto" width="1346" height="574" loading="lazy">

<h4 id="heading-why-this-matters">Why This Matters</h4>
<ul>
<li>The H1 targets the primary keyword, while strong visual engagement helps reduce bounce rate, and a clear CTA improves user interaction signals.</li>
</ul>
<h3 id="heading-value-section-keyword-reinforcement">Value Section (Keyword Reinforcement)</h3>
<p>This section strengthens semantic SEO relevance.</p>
<pre><code class="language-html">&lt;section class="value"&gt;
  &lt;h2&gt;Premium Sustainable Running Experience&lt;/h2&gt;
  &lt;p&gt;
    Discover eco friendly running shoes for men and women designed with recycled materials.
  &lt;/p&gt;
&lt;/section&gt;
</code></pre>
<pre><code class="language-css">section {
  padding:80px 20px;
  max-width:1100px;
  margin:auto;
}
</code></pre>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<ul>
<li>It supports keyword variations, improves topical depth, and helps Google better understand search intent.</li>
</ul>
<h3 id="heading-benefits-grid-scannable-seo-content">Benefits Grid (Scannable SEO Content)</h3>
<p>Google favors structured, easy-to-scan content.</p>
<pre><code class="language-html">&lt;section class="benefits"&gt;
  &lt;h2&gt;Why Choose Eco Running Shoes&lt;/h2&gt;
  &lt;div class="grid"&gt;
    &lt;div&gt;Sustainable materials&lt;/div&gt;
    &lt;div&gt;Lower carbon footprint&lt;/div&gt;
    &lt;div&gt;Lightweight design&lt;/div&gt;
  &lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>The styling:</p>
<pre><code class="language-css">.grid {
  display:grid;
  grid-template-columns:repeat(auto-fit,minmax(200px,1fr));
  gap:20px;
}
</code></pre>
<h5 id="heading-the-ui-view">The UI view:</h5>
<img src="https://cdn.hashnode.com/uploads/covers/647d7b660f441a49aa878a9e/91c162ce-b8cd-47a1-8215-04e9d37e161d.png" alt="91c162ce-b8cd-47a1-8215-04e9d37e161d" style="display:block;margin:0 auto" width="1345" height="630" loading="lazy">

<h4 id="heading-why-this-matters">Why This Matters</h4>
<ul>
<li>It supports keyword variations, improves topical depth, and helps Google better understand search intent.</li>
</ul>
<h3 id="heading-product-section-conversion-affiliate-seo">Product Section (Conversion + Affiliate SEO)</h3>
<p>This is where traffic turns into revenue.</p>
<pre><code class="language-html">&lt;!-- PRODUCTS --&gt;
&lt;section id="products" class="products"&gt;
  &lt;h2&gt;Top Picks&lt;/h2&gt;

  &lt;div class="cards"&gt;

    &lt;div class="card"&gt;
      &lt;img src="assets/shoes/allbirds.jpg" alt="Allbirds Tree     Dasher eco-friendly running shoes made from sustainable materials" loading="lazy"&gt;
      &lt;h3&gt;Allbirds Tree Dasher&lt;/h3&gt;
      &lt;p&gt;Lightweight sustainable running shoes.&lt;/p&gt;
      &lt;a href="https://example.com/allbirds-affiliate" target="_blank" class="btn"&gt;Check Price&lt;/a&gt;
    &lt;/div&gt;
 &lt;/div&gt;
&lt;/section&gt;
</code></pre>
<p>The styling:</p>
<pre><code class="language-css">.cards {
  display:grid;
  grid-template-columns:repeat(3,1fr);
  gap:30px;
}

.card {
  background:#111;
  padding:20px;
  border-radius:20px;
}
</code></pre>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<ul>
<li>Lazy loading improves performance, a well-structured product layout drives affiliate conversions, and animations enhance user engagement.</li>
</ul>
<p>The UI view:</p>
<img src="https://cdn.hashnode.com/uploads/covers/647d7b660f441a49aa878a9e/55199b5d-86fe-42a7-a7d5-22c9ef29c907.png" alt="55199b5d-86fe-42a7-a7d5-22c9ef29c907" style="display:block;margin:0 auto" width="1343" height="504" loading="lazy">

<h4 id="heading-avoid-this-common-mistake-for-image-alt-text">Avoid this common mistake for image alt text:</h4>
<pre><code class="language-html">alt="eco shoes eco friendly running shoes best eco shoes cheap eco shoes"
</code></pre>
<p>That hurts SEO more than it helps.</p>
<p>This is bad because:</p>
<ul>
<li><p>It’s keyword stuffing (Google may treat this as spam)</p>
</li>
<li><p>It provides no real description of the image</p>
</li>
<li><p>It creates a poor experience for screen readers (accessibility issue)</p>
</li>
</ul>
<p>What Google prefers:</p>
<p>Alt text should describe the image naturally while including the keyword where relevant.</p>
<h4 id="heading-pro-tip-for-your-whole-site">Pro Tip (for your whole site)</h4>
<p>Use this formula for every product image alt text:</p>
<p><strong>[Product Name] + [Main Feature] + [Keyword]</strong></p>
<p>Example:</p>
<ul>
<li>“Nike Air Zoom eco-friendly running shoes with recycled materials”</li>
</ul>
<p>This improves: SEO relevance, Accessibility, and User experience</p>
<h3 id="heading-comparison-table-high-intent-seo-content">Comparison Table (High-Intent SEO Content)</h3>
<p>This targets buyers ready to decide.</p>
<pre><code class="language-html">&lt;section class="comparison"&gt;
  &lt;h2&gt;Comparison Table&lt;/h2&gt;
  &lt;table&gt;
    &lt;tr&gt;
      &lt;th&gt;Brand&lt;/th&gt;&lt;th&gt;Weight&lt;/th&gt;&lt;th&gt;Material&lt;/th&gt;                   &lt;th&gt;Durability&lt;/th&gt;
      &lt;th&gt;Comfort&lt;/th&gt;&lt;th&gt;Eco Score&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;th&gt;Best For&lt;/th&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Allbirds&lt;/td&gt;&lt;td&gt;Light&lt;/td&gt;&lt;td&gt;Eucalyptus, fibre, sugarcane&lt;/td&gt;&lt;td&gt;High&lt;/td&gt;
      &lt;td&gt;Very Good&lt;/td&gt;&lt;td&gt;8/10&lt;/td&gt;&lt;td&gt;$125&lt;/td&gt;&lt;td&gt;Daily runs&lt;/td&gt;
    &lt;/tr&gt;
&lt;/table&gt;
&lt;/section&gt;
</code></pre>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<ul>
<li>It captures comparison keywords, improves time on page, and increases visibility for "product vs product" searches.</li>
</ul>
<p>The UI view:</p>
<img src="https://cdn.hashnode.com/uploads/covers/647d7b660f441a49aa878a9e/47c40801-7dc0-41c7-8959-169e4c8f12b9.png" alt="47c40801-7dc0-41c7-8959-169e4c8f12b9" style="display:block;margin:0 auto" width="1249" height="509" loading="lazy">

<h3 id="heading-review-section">Review Section</h3>
<pre><code class="language-html">&lt;section class="reviews"&gt;
  &lt;h2&gt;Trusted by Runners&lt;/h2&gt;
  &lt;p&gt;⭐⭐⭐⭐⭐ "Right place to buy the best eco-friendly sneakers"&lt;/p&gt;
  &lt;p&gt;⭐⭐⭐⭐⭐ "Brands they sell are lightweight and durable"&lt;/p&gt;
  &lt;p&gt;⭐⭐⭐⭐ "My order arrived on time, I like their timing!"&lt;/p&gt;
&lt;/section&gt;
</code></pre>
<h4 id="heading-why-this-matters">Why this matters</h4>
<ul>
<li>It builds trust and makes people more likely to click and buy. It also helps SEO by adding real user language and improving engagement.</li>
</ul>
<h3 id="heading-faq-section-search-expansion">FAQ Section (Search Expansion)</h3>
<pre><code class="language-html">&lt;section class="faq"&gt;
  &lt;h2&gt;FAQs&lt;/h2&gt;

  &lt;details&gt;
    &lt;summary&gt;What are eco-friendly running shoes?&lt;/summary&gt;
    &lt;p&gt;Eco-friendly running shoes are made using sustainable or recycled materials like organic cotton, eucalyptus fiber, and recycled plastics to reduce environmental impact.&lt;/p&gt;
  &lt;/details&gt;
&lt;/section&gt;
</code></pre>
<p>The UI view:</p>
<img src="https://cdn.hashnode.com/uploads/covers/647d7b660f441a49aa878a9e/c17e8bdf-603c-4935-9cfa-b0348585d0f5.png" alt="c17e8bdf-603c-4935-9cfa-b0348585d0f5" style="display:block;margin:0 auto" width="1055" height="626" loading="lazy">

<h4 id="heading-why-this-matters">Why this matters</h4>
<ul>
<li>It targets long-tail keywords, supports featured snippets, and reinforces schema relevance.</li>
</ul>
<h3 id="heading-cta-section-conversion-signal">CTA Section (Conversion Signal)</h3>
<pre><code class="language-html">&lt;section class="cta"&gt;
  &lt;h2&gt;Start Running Sustainably&lt;/h2&gt;
  &lt;a href="#products" class="btn"&gt;Shop Now&lt;/a&gt;
&lt;/section&gt;
</code></pre>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<ul>
<li>It encourages user action, improves engagement metrics, and signals to Google that the content is useful and valuable.</li>
</ul>
<h3 id="heading-footer">Footer</h3>
<pre><code class="language-html">&lt;!-- FOOTER --&gt;
&lt;footer class="footer"&gt;

  &lt;div class="footer-top"&gt;

    &lt;div class="footer-brand"&gt;
      &lt;img src="assets/logo.png" alt="Logo" class="logo"&gt;
      &lt;p&gt;Trusted eco-friendly product reviews.&lt;/p&gt;
    &lt;/div&gt;
  &lt;div class="footer-bottom"&gt;
    &lt;p&gt;© 2026 Eco Running Guide&lt;/p&gt;
  &lt;/div&gt;

&lt;/footer&gt;
</code></pre>
<p>The UI view:</p>
<img src="https://cdn.hashnode.com/uploads/covers/647d7b660f441a49aa878a9e/2afd2119-b3f8-4513-8989-353e37491d0e.png" alt="2afd2119-b3f8-4513-8989-353e37491d0e" style="display:block;margin:0 auto" width="1337" height="527" loading="lazy">

<h4 id="heading-why-this-matters">Why This Matters</h4>
<ul>
<li>The footer builds trust, provides important legal and navigation links, and supports SEO by reinforcing site credibility.</li>
</ul>
<h3 id="heading-javascript-ux-enhancements">JavaScript UX Enhancements</h3>
<pre><code class="language-html">&lt;html&gt;
.
.
.
&lt;body&gt;
.
.
.
&lt;script src="script.js"&gt;&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<h5 id="heading-javascript-code-snippet"><strong>JavaScript code snippet:</strong></h5>
<pre><code class="language-javascript">// Smooth scroll
document.querySelectorAll('a[href^="#"]').forEach(anchor=&gt;{
  anchor.addEventListener("click",function(e){
    e.preventDefault();
    document.querySelector(this.getAttribute("href"))
    .scrollIntoView({behavior:"smooth"});
  });
});

// Fade-in cards
const cards = document.querySelectorAll('.card');
cards.forEach(card=&gt;{
  card.style.opacity = 0;
  card.style.transform = "translateY(20px)";
});

window.addEventListener('scroll', ()=&gt;{
  cards.forEach(card=&gt;{
    const rect = card.getBoundingClientRect();
    if(rect.top &lt; window.innerHeight - 50){
      card.style.opacity = 1;
      card.style.transform = "translateY(0)";
      card.style.transition = "0.5s";
    }
  });
});
</code></pre>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<ul>
<li>The script link ensures interactive features (like smooth scrolling and animations) load properly, improving user experience and engagement.</li>
</ul>
<p>Note that the above code snippets are part of the main code file. You can copy or clone the project code files from this <a href="https://github.com/nuelcas/seo-landing-page.git">repository</a>.</p>
<h2 id="heading-deployment-using-netlify">Deployment (Using Netlify)</h2>
<h3 id="heading-why-netlify">Why Netlify?</h3>
<p>Netlify is one of the easiest and fastest ways to deploy modern websites. It’s widely used by developers because it has:</p>
<ul>
<li><p>Free hosting for small to medium projects</p>
</li>
<li><p>Automatic deployment from GitHub (no manual uploads)</p>
</li>
<li><p>A fast global CDN (your site loads quickly anywhere)</p>
</li>
<li><p>Built-in HTTPS (SSL security included)</p>
</li>
<li><p>Simple custom domain setup</p>
</li>
<li><p>It's perfect for static sites (HTML, CSS, JS landing pages)</p>
</li>
</ul>
<p>In simple terms, Netlify removes the stress of server setup and lets you focus on building and improving your site.</p>
<h3 id="heading-step-1-push-your-project-to-github">Step 1 — Push Your Project to GitHub</h3>
<p>Before anything, make sure your landing page project is already uploaded to GitHub.</p>
<h4 id="heading-connect-to-netlify">Connect to Netlify</h4>
<ol>
<li><p>Go to <a href="https://app.netlify.com/">Netlify</a> and sign up or log in.</p>
</li>
<li><p>Click “Add new site”</p>
</li>
<li><p>Select “Import an existing project”</p>
</li>
<li><p>Choose GitHub</p>
</li>
<li><p>Authorize Netlify to access your repositories</p>
</li>
</ol>
<h4 id="heading-select-your-repository">Select Your Repository</h4>
<p>Then find your project repo (for example,<code>seo-landing-landing</code>) and click on it. Netlify will automatically detect it as a static site.</p>
<h4 id="heading-deploy-the-site">Deploy the Site</h4>
<p>Now click “Deploy site”. Netlify will build and host your project. After a few seconds, you’ll get a live URL.</p>
<p>Example:</p>
<pre><code class="language-plaintext">https://ecorunningshoes.netlify.app/
</code></pre>
<h4 id="heading-adding-a-custom-domain">Adding a Custom Domain</h4>
<p>To make your site look professional (instead of using <code>netlify.app</code>), you can connect your own domain like:</p>
<pre><code class="language-plaintext">www.yourdomain.com
</code></pre>
<h5 id="heading-steps"><strong>Steps</strong>:</h5>
<ol>
<li><p>Go to your Netlify dashboard</p>
</li>
<li><p>Open your deployed site</p>
</li>
<li><p>Click “Domain settings”</p>
</li>
<li><p>Select “Add custom domain”</p>
</li>
<li><p>Enter your domain name (e.g. <code>ecorunshoes.com</code>)</p>
</li>
<li><p>Click "verify"</p>
</li>
</ol>
<h4 id="heading-configure-dns-important">Configure DNS (Important)</h4>
<p>After adding your domain, go to your domain provider (for example, Namecheap, GoDaddy) and update the DNS records as Netlify instructs:</p>
<ul>
<li><p>Add CNAME record pointing to Netlify</p>
</li>
<li><p>Or use Netlify DNS (recommended) for easier setup</p>
</li>
</ul>
<h3 id="heading-enable-https">Enable HTTPS</h3>
<p>Once the domain is connected, Netlify automatically issues a free SSL certificate. Your site becomes secure:</p>
<pre><code class="language-plaintext">https://yourdomain.com
</code></pre>
<h3 id="heading-publish-and-index">Publish and Index</h3>
<p>After deployment, make sure to submit the URL to <a href="https://search.google.com/search-console/welcome">Google Search Console</a> and request indexing.</p>
<h3 id="heading-post-publish-this-is-where-ranking-happens">Post-Publish (This Is Where Ranking Happens)</h3>
<p>Most pages don’t rank because people stop at publishing.</p>
<p>There are a few more things you should do before you're done:</p>
<h4 id="heading-build-backlinks">Build backlinks</h4>
<ul>
<li><p>Add guest posts</p>
</li>
<li><p>Blog mentions</p>
</li>
<li><p>Directories</p>
</li>
</ul>
<h4 id="heading-update-content">Update content</h4>
<ul>
<li><p>Add new products</p>
</li>
<li><p>Improve sections</p>
</li>
<li><p>Refresh FAQs</p>
</li>
</ul>
<h3 id="heading-turn-this-into-a-system-advanced-move">Turn This Into a System (Advanced Move)</h3>
<p>Don’t just build ONE page. Build a cluster system:</p>
<p>The main page will focus on "best eco-friendly running shoes".</p>
<p>The Supporting pages:</p>
<ul>
<li><p>best vegan running shoes</p>
</li>
<li><p>eco-friendly gym wear</p>
</li>
<li><p>sustainable shoe brands</p>
</li>
</ul>
<p>Link them together so authority increases and rankings grow faster.</p>
<h3 id="heading-simple-reality-check">Simple Reality Check</h3>
<p>Keyword research is only <strong>20% of SEO</strong>.</p>
<p>The other 80% is:</p>
<ul>
<li><p>Structure</p>
</li>
<li><p>Intent matching</p>
</li>
<li><p>Content depth</p>
</li>
<li><p>Authority (backlinks)</p>
</li>
</ul>
<p>When these work together, your page doesn’t just rank — it sells.</p>
<h2 id="heading-final-result">Final Result</h2>
<p>You now have a live SEO landing page that's hosted for free on Netlify and automatically updated from GitHub, as well as a professional custom domain (optional but recommended).</p>
<p>A high-ranking SEO landing page works best when strategy, structure, and intent are properly aligned. Good keyword research, SERP analysis, and a clear page layout all contribute to both visibility and conversions.</p>
<p>When combined with solid on-page SEO and proper deployment, your landing page becomes a reliable asset that can attract traffic and deliver results over time. Success in SEO also depends on consistent updates and ongoing optimization after publishing.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Compress PDF Files in the Browser Using JavaScript (Step-by-Step) ]]>
                </title>
                <description>
                    <![CDATA[ PDF files are everywhere. From invoices and reports to résumés and documents, they’re one of the most common file formats we deal with. But there’s a common problem: PDFs can get large quickly. If you ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-compress-pdf-files-in-the-browser-using-javascript/</link>
                <guid isPermaLink="false">69f8b15246610fd606f2d8da</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ compression ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Mon, 04 May 2026 14:46:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/c46817cf-6587-42c5-b7c1-53b074e77d0a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>PDF files are everywhere. From invoices and reports to résumés and documents, they’re one of the most common file formats we deal with. But there’s a common problem: PDFs can get large quickly.</p>
<p>If you’ve ever tried to upload a PDF and hit a file size limit, you’ve already seen why compression matters.</p>
<p>Most tools solve this by uploading your file to a server. That works, but it’s not always ideal, especially when dealing with private or sensitive documents.</p>
<p>The good news is that modern browsers are powerful enough to handle basic PDF compression locally.</p>
<p>In this tutorial, you’ll learn how to build a <strong>browser-based PDF compression tool using JavaScript</strong>, where everything runs directly in the browser.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/8f448f4a-980d-4792-8c2f-60f6a64f00d1.png" alt="browser-based PDF compression tool allinonetool" style="display:block;margin:0 auto" width="768" height="162" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-pdf-compression-works">How PDF Compression Works</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-what-library-are-we-using">What Library Are We Using?</a></p>
</li>
<li><p><a href="#heading-creating-the-upload-interface">Creating the Upload Interface</a></p>
</li>
<li><p><a href="#heading-reading-the-pdf-file">Reading the PDF File</a></p>
</li>
<li><p><a href="#heading-understanding-compression-strategy">Understanding Compression Strategy</a></p>
</li>
<li><p><a href="#heading-compressing-the-pdf">Compressing the PDF</a></p>
</li>
<li><p><a href="#heading-generating-and-downloading-the-file">Generating and Downloading the File</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-compression-tool-works">Demo: How the PDF Compression Tool Works</a></p>
</li>
<li><p><a href="#heading-important-notes-from-real-world-use">Important Notes from Real-World Use</a></p>
</li>
<li><p><a href="#heading-common-mistakes-to-avoid">Common Mistakes to Avoid</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-how-pdf-compression-works">How PDF Compression Works</h2>
<p>PDF compression is different from image compression.</p>
<p>A PDF isn't just a single image. It’s a structured document that can include text, images, fonts, and metadata. Because of this, reducing its size involves optimizing multiple parts of the file rather than applying a single compression method.</p>
<p>In most cases, compressing a PDF means lowering image quality where possible, removing unnecessary or unused data, and optimizing how the document is internally structured.</p>
<p>When working in the browser, we don’t have the same level of control as server-side tools. But we can still reduce file size by reprocessing the document and saving it in a more efficient format.</p>
<p>This approach may not achieve extreme compression, but it works well for creating lighter, more efficient files while keeping everything fast and private.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>This project is simple.</p>
<p>You only need:</p>
<ul>
<li><p>an HTML file</p>
</li>
<li><p>JavaScript</p>
</li>
<li><p>a PDF library</p>
</li>
</ul>
<p>No backend is required. Everything runs locally in the browser.</p>
<h2 id="heading-what-library-are-we-using">What Library Are We Using?</h2>
<p>We’ll use <strong>pdf-lib</strong>, which allows us to load and recreate PDF files.</p>
<p>Add it using a CDN:</p>
<pre><code class="language-html">&lt;script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"&gt;&lt;/script&gt;
</code></pre>
<h2 id="heading-creating-the-upload-interface">Creating the Upload Interface</h2>
<p>Start with a simple interface:</p>
<pre><code class="language-html">&lt;input type="file" id="upload" accept="application/pdf"&gt;
&lt;button onclick="compressPDF()"&gt;Compress PDF&lt;/button&gt;

&lt;a id="download" style="display:none;"&gt;Download Compressed PDF&lt;/a&gt;
</code></pre>
<p>This allows users to upload a PDF, trigger compression, and download the result once ready.</p>
<h2 id="heading-reading-the-pdf-file">Reading the PDF File</h2>
<p>Now read the uploaded file:</p>
<pre><code class="language-javascript">const fileInput = document.getElementById("upload");

if (!fileInput.files.length) {
  alert("Please upload a PDF");
  return;
}

const file = fileInput.files[0];
const arrayBuffer = await file.arrayBuffer();
</code></pre>
<h2 id="heading-understanding-compression-strategy">Understanding Compression Strategy</h2>
<p>Since we’re working in the browser, we don’t have full low-level control over PDF compression.</p>
<p>Instead, we focus on practical optimizations that help reduce file size without affecting usability too much. This includes recreating the document structure in a more efficient way, removing unnecessary metadata, and reducing image quality where possible.</p>
<p>The goal here isn’t perfect compression, but producing a lighter file while maintaining acceptable visual quality and readability.</p>
<h2 id="heading-compressing-the-pdf">Compressing the PDF</h2>
<p>Here’s the core logic:</p>
<pre><code class="language-javascript">async function compressPDF() {
  const fileInput = document.getElementById("upload");

  if (!fileInput.files.length) {
    alert("Please upload a PDF");
    return;
  }

  const file = fileInput.files[0];
  const arrayBuffer = await file.arrayBuffer();

  const { PDFDocument } = PDFLib;

  const originalPdf = await PDFDocument.load(arrayBuffer);
  const newPdf = await PDFDocument.create();

  const pages = await newPdf.copyPages(
    originalPdf,
    originalPdf.getPageIndices()
  );

  pages.forEach(page =&gt; newPdf.addPage(page));

  const pdfBytes = await newPdf.save({
    useObjectStreams: true
  });

  const blob = new Blob([pdfBytes], { type: "application/pdf" });

  const link = document.getElementById("download");
  link.href = URL.createObjectURL(blob);
  link.download = "compressed.pdf";
  link.style.display = "inline";
  link.innerText = "Download Compressed PDF";
}
</code></pre>
<p>This recreates the PDF using optimized object streams, which can reduce file size.</p>
<h2 id="heading-generating-and-downloading-the-file">Generating and Downloading the File</h2>
<p>Once processed:</p>
<pre><code class="language-javascript">link.href = URL.createObjectURL(blob);
link.download = "compressed.pdf";
</code></pre>
<p>The file is downloaded instantly, without any server interaction.</p>
<h2 id="heading-demo-how-the-pdf-compression-tool-works">Demo: How the PDF Compression Tool Works</h2>
<p>Here’s how the full flow looks in a real-world scenario using the browser-based PDF compression tool.</p>
<h3 id="heading-step-1-upload-pdf">Step 1: Upload PDF</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/7ae27903-73d3-4897-9214-bcb061ab256a.png" alt="PDF compression tool interface showing drag and drop upload area with select file button" style="display:block;margin:0 auto" width="1409" height="662" loading="lazy">

<p>Start by uploading your PDF file. You can either drag and drop the file into the upload area or click the “Select PDF” button to choose a file from your device.</p>
<h3 id="heading-step-2-preview-the-pdf">Step 2: Preview the PDF</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/8b713246-98cf-4533-8d39-a5dbd89ac181.png" alt="PDF file preview interface with page navigation controls in browser-based compression tool" style="display:block;margin:0 auto" width="796" height="679" loading="lazy">

<p>Once the file is loaded, the tool displays a preview of the document. You can navigate between pages to confirm that the correct file has been uploaded before applying compression.</p>
<h3 id="heading-step-3-choose-compression-settings">Step 3: Choose Compression Settings</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/e8612b77-502d-4b53-b7d1-835061fc8e37.png" alt="PDF compression settings showing levels like basic, recommended, high and advanced options" style="display:block;margin:0 auto" width="391" height="681" loading="lazy">

<p>Next, select the compression level based on your needs. Lower compression keeps better quality, while higher compression reduces file size more aggressively. You can also explore advanced options like metadata handling.</p>
<h3 id="heading-step-4-compress-the-pdf">Step 4: Compress the PDF</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/9450ece1-b064-4184-a267-f6e58b9cddf9.png" alt="Compress PDF button with start over option in browser-based PDF compression tool" style="display:block;margin:0 auto" width="424" height="97" loading="lazy">

<p>Click the “Compress PDF” button to start the process. The tool processes everything directly in your browser, without uploading files to any server.</p>
<h3 id="heading-step-5-download-the-compressed-file">Step 5: Download the Compressed File</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/a815b5c7-be0b-493d-b1be-6dc7d29b955e.png" alt="PDF compression result showing reduced file size and download button for optimized file" style="display:block;margin:0 auto" width="1085" height="802" loading="lazy">

<p>After compression is complete, you’ll see the final result along with the reduced file size. You can then rename and download the optimized PDF instantly.</p>
<h2 id="heading-important-notes-from-real-world-use">Important Notes from Real-World Use</h2>
<p>When working with PDF compression in the browser, handling large files becomes important.</p>
<p>If a user uploads a very large PDF, processing everything at once can slow down the browser or even cause it to freeze. Instead of trying to process everything blindly, it’s better to add checks and handle files carefully.</p>
<p>For example, you can limit the file size before processing:</p>
<pre><code class="language-javascript">const MAX_SIZE = 10 * 1024 * 1024; // 10MB

if (file.size &gt; MAX_SIZE) {
  alert("File is too large. Please upload a file under 10MB.");
  return;
}
</code></pre>
<p>This prevents performance issues and keeps the tool responsive.</p>
<p>Another useful approach is to process files step by step instead of doing everything at once:</p>
<pre><code class="language-javascript">const { PDFDocument } = PDFLib;

const originalPdf = await PDFDocument.load(arrayBuffer);
const newPdf = await PDFDocument.create();

for (let i = 0; i &lt; originalPdf.getPageCount(); i++) {
  const [page] = await newPdf.copyPages(originalPdf, [i]);
  newPdf.addPage(page);
}
</code></pre>
<p>This spreads the work across smaller steps and avoids blocking the browser.</p>
<p>It’s also important to remember that everything runs client-side. This means files never leave the user’s device, which is great for privacy. But it also means performance depends on the user’s device, so keeping processing efficient is important.</p>
<h2 id="heading-common-mistakes-to-avoid">Common Mistakes to Avoid</h2>
<p>One common mistake is not validating user input properly before processing the file.</p>
<p>For example, users might try to upload an empty file, a non-PDF file, or even trigger the compression without selecting anything. It’s important to check these cases early to avoid errors later in the process:</p>
<pre><code class="language-javascript">const fileInput = document.getElementById("upload");

if (!fileInput.files.length) {
  alert("Please upload a PDF file.");
  return;
}

const file = fileInput.files[0];

if (file.type !== "application/pdf") {
  alert("Only PDF files are supported.");
  return;
}
</code></pre>
<p>Another issue is allowing invalid or unexpected input to pass through. Even something as simple as an empty or corrupted file can cause the PDF processing to fail, so basic validation makes the tool much more reliable.</p>
<p>Handling large files without any checks is another common problem. If a very large PDF is processed without limits, it can slow down the browser or even make the page unresponsive. Adding a simple file size check helps prevent this:</p>
<pre><code class="language-javascript">const MAX_SIZE = 10 * 1024 * 1024; // 10MB

if (file.size &gt; MAX_SIZE) {
  alert("File is too large. Please upload a file under 10MB.");
  return;
}
</code></pre>
<p>Another mistake is assuming that compression will always produce a significantly smaller file. In reality, browser-based compression is limited compared to dedicated server-side tools, so results can vary depending on the content of the PDF.</p>
<p>In practice, most issues come from missing validation and handling edge cases. Adding a few simple checks early makes the tool more stable and improves the overall user experience.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF compression tool using JavaScript.</p>
<p>You learned how to read and recreate PDF files, apply basic optimizations, and generate a downloadable file entirely in the browser.</p>
<p>If you’d like to try a complete version of this idea, you can check it out here: <a href="https://allinonetools.net/pdf-compressor/">https://allinonetools.net/pdf-compressor/</a></p>
<p>This approach keeps everything fast, private, and simple to use.</p>
<p>Once you understand this pattern, you can extend it further to build more advanced document tools.</p>
<p>And that’s where things start getting really interesting.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How I Completed 15 freeCodeCamp Certifications in 4 Months: 
A Structured Learning Journey ]]>
                </title>
                <description>
                    <![CDATA[ Can you achieve a massive milestone while you're still in high school other than just getting high grades? You may be thinking: school alone is plenty of work! And it often is. But if you set your min ]]>
                </description>
                <link>https://www.freecodecamp.org/news/freecodecamp-15-certifications-in-4-months-high-school/</link>
                <guid isPermaLink="false">69f212ea6e0124c05e18f7b0</guid>
                
                    <category>
                        <![CDATA[ freeCodeCamp.org ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Learning Journey ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mohammed Fahd Abrah ]]>
                </dc:creator>
                <pubDate>Wed, 29 Apr 2026 14:17:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/95e36f70-6cd4-4349-9fdc-13ce2b73a3b5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Can you achieve a massive milestone while you're still in high school other than just getting high grades?</p>
<p>You may be thinking: school alone is plenty of work! And it often is. But if you set your mind to it, like I did, you'll be amazed at what you can do.</p>
<p>In this story, I’ll share my journey of working through and receiving 15 freeCodeCamp certifications in just four months.</p>
<h3 id="heading-what-ill-cover">What I'll Cover:</h3>
<ul>
<li><p><a href="#heading-my-beginning-with-the-digital-world">My Beginning with the Digital World</a></p>
</li>
<li><p><a href="#heading-starting-my-journey-with-freecodecamp">Starting My Journey with freeCodeCamp</a></p>
</li>
<li><p><a href="#heading-benefits-of-freecodecamps-methodology">Benefits of freeCodeCamp's Methodology</a></p>
</li>
<li><p><a href="#heading-freecodecamp-learning-paths">freeCodeCamp Learning Paths</a></p>
</li>
<li><p><a href="#heading-1-responsive-web-design-certification">1- Responsive Web Design Certification.</a></p>
</li>
<li><p><a href="#heading-2-javascript-algorithms-and-data-structures">2- JavaScript Algorithms and Data Structures</a></p>
</li>
<li><p><a href="#heading-3-scientific-computing-with-python">3- Scientific Computing with Python</a></p>
</li>
<li><p><a href="#heading-4-data-visualization">4- Data Visualization</a></p>
</li>
<li><p><a href="#heading-5-backend-end-development-and-apis">5- Backend End Development and APIs</a></p>
</li>
<li><p><a href="#heading-6-front-end-development-libraries">6- Front End Development Libraries</a></p>
</li>
<li><p><a href="#heading-7-data-analysis-with-python">7- Data Analysis with Python</a></p>
</li>
<li><p><a href="#heading-8-machine-learning-with-python">8- Machine Learning with Python</a></p>
</li>
<li><p><a href="#heading-9-quality-assurance">9- Quality Assurance</a></p>
</li>
<li><p><a href="#heading-10-information-security">10- Information Security</a></p>
</li>
<li><p><a href="#heading-11-legacy-certifications">11- Legacy Certifications</a></p>
</li>
<li><p><a href="#heading-personal-recommendations">Personal recommendations</a></p>
</li>
</ul>
<h2 id="heading-my-beginning-with-the-digital-world">My Beginning with the Digital World</h2>
<p>I grew up in a family that really believed in life-long learning.</p>
<p>At an early age – around 10 – my father bought me my first laptop.</p>
<p>From there, learning became part of our daily routine. My father approached it with structure and intention. He designed a complete detailed learning plan for me.</p>
<p>Looking back, it was quite ambitious for someone my age. But my father always believed in high standards and expectations.</p>
<p>Still, we didn’t start with programming right away.</p>
<p>At first, we explored different areas and domains. I focused on trying to find something I though was interesting.</p>
<p>But it didn’t take long before we realized how important programming was becoming and how powerful it could be to start early.</p>
<p>So, we decided that I should start learning programming.</p>
<p>I began with HTML, building my very first web page. I was able to build a complete web page using different elements and tags on my own.</p>
<p>It was simple but it worked. That moment felt like a win.</p>
<p>Then I moved on to CSS. I was able to style and arrange the elements on the web page the way I liked. I grasped many CSS techniques and commands that help control the layout and arragement of elements so I could make them look the way I wanted.</p>
<p>After that came JavaScript. That’s when I was able to make things more alive. I started adding movement, interaction, and behavior to my web pages.</p>
<p>And I didn’t stop there.</p>
<p>I stepped into backend development with PHP, beginning to understand how things worked behind the scenes. Alongside that, I started learning SQL to handle databases – an essential part of building real, functional web applications.</p>
<p>Step by step, the picture was becoming clearer.</p>
<p>Before learning about these languages, the web sound like a black box. But after I finished learning them, I started looking at websites from a different angle, and I started recognizing how web pages are made.</p>
<p>All this learning came through a mix of YouTube lessons and structured courses my father invested in for me, like a 50-hour PHP course on Udemy.</p>
<p>I was absorbing a lot, moving from one concept to another, and building small pieces along the way.</p>
<p>But at some point, something clicked: I realized that watching tutorials – even long, detailed ones – wasn’t enough on its own. There was a gap between understanding concepts and building something real. So I decided I needed to dive deeper.</p>
<h2 id="heading-starting-my-journey-with-freecodecamp">Starting My Journey with freeCodeCamp</h2>
<p>I needed to move beyond lessons into building structured, meaningful web applications. Projects that weren’t just exercises, but had real purpose.</p>
<p>Projects with expectations, constraints, and even real stakeholders.</p>
<p>The kind of work that forces you to think, to make decisions, and to take ownership.</p>
<p>Because there’s a big difference between following along with a video… and sitting alone in front of a blank screen, figuring things out step by step.</p>
<p>That shift helped me avoid what many learners fall into: the endless loop of tutorials without real progress (<a href="https://www.freecodecamp.org/news/how-to-break-free-from-tutorial-hell/">Tutorial Hell</a>).</p>
<p>And for the first time, I started to feel what it really means to build.</p>
<p>That’s when I made a clear decision to switch to freeCodeCamp. What drew me in was simple: it wasn’t just lessons. It was practice building real, structured, hands-on projects.</p>
<h2 id="heading-benefits-of-freecodecamps-methodology">Benefits of freeCodeCamp's Methodology</h2>
<p>After completing 15 certifications on freeCodeCamp, I was able to build and launch a full platform called Programming Ocean Academy, focused on Data Science and Artificial Intelligence.</p>
<p>It pushed me to think, to solve problems on my own, and to act like an engineer – not just a learner following instructions.</p>
<p>This wasn’t a small project. It included:</p>
<ul>
<li><p>A fully functional frontend and backend system</p>
</li>
<li><p>More than 25 databases</p>
</li>
<li><p>Over 150 pages</p>
</li>
<li><p>Integrated training platforms</p>
</li>
</ul>
<p>But what mattered more than the scale… was what came next.</p>
<p>Because of the strong logical and programming foundation I had built, transitioning into Data Science and AI felt natural and not overwhelming.</p>
<p>I moved into Python and its ecosystem with confidence. From there, I worked with powerful libraries like scikit-learn, TensorFlow, and PyTorch.</p>
<p>The solid foundation I'd built enabled me to deliver multiple training programs in collaboration with Arab universities, and I've helped train more than 5,000 learners.</p>
<p>Looking back, that shift from consuming content to building real systems and delivering courses was the turning point.</p>
<h2 id="heading-freecodecamp-learning-paths">freeCodeCamp Learning Paths</h2>
<p>Today, I’m happy to share this journey with you and to emphasize something I’ve come to believe deeply: the programs and learning paths offered by freeCodeCamp aren't just courses. They're a structured bridge that'll help take you from being someone who watches tutorials and writes code to someone who builds real applications and creates products that serve people.</p>
<p>Now, you have the context you need to understand the rest of the story.</p>
<p>So let’s begin.</p>
<p>This is where the journey with freeCodeCamp really starts. A journey I would confidently recommend to anyone who wants to enter the world of programming and technology with clarity and direction.</p>
<p>How did it start? And how did I choose my path?</p>
<p>At the beginning, I didn’t approach freeCodeCamp randomly.</p>
<p>I knew that if I wanted real progress, I needed structure.</p>
<p>So instead of jumping between topics, I followed a clear order – one that builds understanding step by step, just like constructing a solid foundation before raising a building.</p>
<p>I asked myself a simple question: What do I need to master first, so everything that comes after becomes easier not harder?</p>
<p>That question influenced everything that followed.</p>
<p>So instead of creating my own path from scratch, <strong>I decided to fully trust the methodology of freeCodeCamp,</strong> following its order of certifications, lessons, and progression exactly as designed.</p>
<p>That decision made everything simpler.</p>
<p>I started from the very beginning and moved step by step.</p>
<p>My journey began with:</p>
<h2 id="heading-1-responsive-web-design-certification">1: <strong>Responsive Web Design Certification.</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/673b4872-61f7-4f5c-bb34-aca354bb0b49.png" alt="673b4872-61f7-4f5c-bb34-aca354bb0b49" style="display:block;margin:0 auto" width="1327" height="885" loading="lazy">

<p>At that time, I was studying for around 8 hours a day on most days, balancing it with my school responsibilities. It wasn’t always easy, but the structure kept me focused.</p>
<p>During this first phase, I built a strong foundation.</p>
<p>I explored HTML in depth:</p>
<ul>
<li><p>Understanding almost all HTML tags</p>
</li>
<li><p>Knowing the purpose of each element</p>
</li>
<li><p>Learning which attributes belong to which elements</p>
</li>
<li><p>When to use each tag properly</p>
</li>
<li><p>Writing clean, semantic code that follows best practices</p>
</li>
</ul>
<p>Then came CSS. This is where things evolved visually.</p>
<p>I started understanding more deeply how to:</p>
<ul>
<li><p>Style and structure pages</p>
</li>
<li><p>Create modern, clean layouts</p>
</li>
<li><p>Build responsive designs that adapt across devices</p>
</li>
</ul>
<p>But the real test wasn’t the lessons.</p>
<p>To earn the certification, I had to complete five full projects, each one requiring me to apply everything I had learned, solve problems independently, and choose the best possible approach rather than just making things “work.”</p>
<p>That’s where the real learning happened.</p>
<h2 id="heading-2-javascript-algorithms-and-data-structures"><strong>2: JavaScript Algorithms and Data Structures</strong></h2>
<p>For the second certification, JavaScript, things took a different turn.</p>
<p>This is where the web stopped being static.</p>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/08229454-a3ef-438c-b8ce-4016ffed976e.png" alt="08229454-a3ef-438c-b8ce-4016ffed976e" style="display:block;margin:0 auto" width="1324" height="890" loading="lazy">

<p>I learned how to make pages interactive and alive. I learned how to control behavior, respond to user actions, and build logic that does something. But more importantly, I spent time learning how to think logically.</p>
<p>JavaScript pushed me into algorithmic thinking:</p>
<ul>
<li><p>Breaking problems into smaller steps</p>
</li>
<li><p>Writing logic in a structured, methodical way</p>
</li>
<li><p>Building solutions that are not just correct but clean and scalable</p>
</li>
</ul>
<p>And after that phase, I didn’t stop at just using freeCodeCamp's curriculum.</p>
<p>I wanted to go deeper.</p>
<p>So I started solving programming challenges on platforms like Codewars and Edabit. Those challenges sharpened my thinking even more. They forced me to face unfamiliar problems and figure things out without guidance.</p>
<h2 id="heading-3-scientific-computing-with-python"><strong>3: Scientific Computing with Python</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/9ee958bb-c122-4d74-a598-6a8b1f39e257.png" alt="9ee958bb-c122-4d74-a598-6a8b1f39e257" style="display:block;margin:0 auto" width="1318" height="882" loading="lazy">

<p>Then came the third stage of the journey.</p>
<p>This phase was different. Python had its own elegance, its own logic, and a strong connection to mathematics and data.</p>
<p>It opened a completely new way of thinking.</p>
<p>Through hands-on projects, I learned how to work with data using powerful tools like NumPy, pandas, and Matplotlib. And I didn’t just learn how to use these tools. I got familiar with what they enable.</p>
<p>I practiced:</p>
<ul>
<li><p>Analyzing data</p>
</li>
<li><p>Exploring patterns</p>
</li>
<li><p>Visualizing insights</p>
</li>
<li><p>Thinking statistically</p>
</li>
<li><p>Moving from raw data to meaningful conclusions</p>
</li>
</ul>
<p>I began to understand how data can be transformed into real insights That’s when my skills started to become more powerful.</p>
<p>My first real encounter with Python and data analysis was through freeCodeCamp.</p>
<p>Unlike web development – which I had explored earlier through different resources – this was my first entry point into the world of data.</p>
<p>And for that, I honestly give freeCodeCamp a lot of credit. It didn’t just introduce me to new tools. It introduced me to a completely new way of thinking.</p>
<h2 id="heading-4-data-visualization">4: <strong>Data Visualization</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/5e54f5bd-ffab-4ff7-a485-fcf5ec3fd60b.png" alt="5e54f5bd-ffab-4ff7-a485-fcf5ec3fd60b" style="display:block;margin:0 auto" width="1316" height="877" loading="lazy">

<p>This phase added a new dimension. It wasn’t just about working with data anymore – it was about communicating it's meaning.</p>
<p>I learned how to transform raw numbers into clear, meaningful visualizations. I explored how to create graphs that don’t just look good but help you understand what’s going on beneath the surface.</p>
<p>That experience was incredibly valuable</p>
<p>It built a foundation that later made my transition from web development into data science and AI much smoother.</p>
<p>And once again, I must acknowledge the role of freeCodeCamp. Because during this phase working with tools like Python, Matplotlib, and pandas, I began to truly understand the importance of data visualization and analysis.</p>
<p>I started to carry this mindset back into the world of web development:</p>
<ul>
<li><p>Into databases</p>
</li>
<li><p>Into SQL tables</p>
</li>
<li><p>Into how data is structured, queried, and interpreted</p>
</li>
</ul>
<p>I realized that data isn't just something you store. Its value comes from how well you can understand it, analyze it, and use it.</p>
<p>And for stakeholders, this is just as critical as storage, security, and privacy because without insight, data alone means very little.</p>
<p>This distinction is incredibly important for every developer to understand.</p>
<p>In the world of web development, the focus is often on storing data, securing it, and making sure it’s accessible. But in the world of data analysis, scientific computing, and statistical modeling, the focus shifts completely.</p>
<p>It becomes about studying the data itself transforming it from something silent… into something that speaks. Something that guides decisions. Something that helps you improve systems, refine products, and make smarter long-term choices.</p>
<p>That shift in perspective changed the way I handled everything.</p>
<h2 id="heading-5-backend-end-development-and-apis">5: <strong>Backend End Development and APIs</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/b776d911-3511-4f49-b877-23a991a23cef.png" alt="b776d911-3511-4f49-b877-23a991a23cef" style="display:block;margin:0 auto" width="1336" height="899" loading="lazy">

<p>This was a new world.</p>
<p>Even though I had previous experience with PHP and SQL from Udemy, this path introduced me to a different ecosystem which is modern, fast, and widely used in real-world applications.</p>
<p>Of course, the beginning wasn’t easy. I had no prior experience with tools like Node.js or MongoDB. It felt unfamiliar at first, and there was a learning curve.</p>
<p>But this is where freeCodeCamp stood out again.</p>
<p>They didn’t just throw you alone into the deep end. They supported the journey.</p>
<p>I found dedicated courses on their YouTube channel like a full Node.js course (around 8 hours) and a MongoDB course (around 4 hours).</p>
<p>I went through both of them completely. Step by step, things started to make sense. I built a solid foundation, returned to the certification path, and this time I was ready.</p>
<p>I completed all the challenges and projects successfully, from the first attempt.</p>
<p>And that experience taught me something important: sometimes the path forward isn’t about pushing harder, it’s about stepping back, strengthening your foundations, and then coming back stronger.</p>
<p>One of the most interesting parts of this stage was discovering the difference between how data is handled in SQL versus MongoDB.</p>
<p>It wasn’t just a technical difference, but a shift in mindset.</p>
<p>With SQL, everything is structured, relational, and predefined. With MongoDB, things are more flexible, document-based, and dynamic.</p>
<p>Learning to work with both gave me a broader perspective on how to design and manage data depending on the problem at hand.</p>
<h2 id="heading-6-front-end-development-libraries">6: Front End Development Libraries</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/23248ac0-2711-41a5-aa14-bc42deda294d.png" alt="23248ac0-2711-41a5-aa14-bc42deda294d" style="display:block;margin:0 auto" width="1323" height="882" loading="lazy">

<p>This was one of the most enjoyable phases. It felt creative, fast, and powerful.</p>
<p>I explored frameworks and libraries like:</p>
<ul>
<li><p>jQuery</p>
</li>
<li><p>React</p>
</li>
<li><p>Vue.js</p>
</li>
</ul>
<p>To strengthen my understanding, I followed additional courses on the freeCodeCamp YouTube channel, making sure I had the right foundations before tackling the projects and passing the certification requirements.</p>
<p>What stood out to me the most during this phase was something new: for the first time, I truly learned how to control HTML and CSS through JavaScript in a structured and scalable way.</p>
<p>This wasn’t just about styling anymore, but it was about building dynamic interfaces, managing state, and creating responsive user experiences.</p>
<p>And honestly, this was the first time I grasped this concept deeply.</p>
<h2 id="heading-7-data-analysis-with-python">7: Data Analysis with Python</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/a3d99b60-0cf3-4245-bd68-d36385ff4fa9.png" alt="a3d99b60-0cf3-4245-bd68-d36385ff4fa9" style="display:block;margin:0 auto" width="1316" height="875" loading="lazy">

<p>Here, things became more precise.</p>
<p>I explored how to:</p>
<ul>
<li><p>Choose the right type of visualization depending on the data</p>
</li>
<li><p>Analyze datasets using tools like Excel, NumPy, and pandas</p>
</li>
<li><p>Create advanced visualizations using libraries like D3.js</p>
</li>
</ul>
<p>I was learning how to think with data, how to read it, question it, and turn it into something meaningful.</p>
<h2 id="heading-8-machine-learning-with-python">8: Machine Learning with Python</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/d2f391be-ff8c-48f5-82c6-682cb44b164f.png" alt="d2f391be-ff8c-48f5-82c6-682cb44b164f" style="display:block;margin:0 auto" width="1314" height="876" loading="lazy">

<p>This new learning path was deeper, more abstract. Sometimes even unfamiliar compared to everything I had learned before.</p>
<p>For the first time, I wasn’t just writing code to build applications. I was building models that learn from data.</p>
<p>Working with tools like TensorFlow, I began to understand how data, mathematics, and algorithms come together to create intelligent systems.</p>
<p>Everything I had learned through freeCodeCamp started to reflect beyond programming itself.</p>
<p>I noticed the impact in school:</p>
<ul>
<li><p>In mathematics, logic became clearer</p>
</li>
<li><p>In digital technology, concepts felt more intuitive</p>
</li>
<li><p>Even in subjects like physics and chemistry, problem-solving became easier</p>
</li>
</ul>
<p>Because at its core, my way of thinking had changed. My logical reasoning had become stronger. Working with algorithms and mathematical expressions no longer felt difficult. Instead it felt natural.</p>
<p>One of the most meaningful outcomes of this journey came during high school. A teacher trusted me with a responsibility I didn’t expect: To explain programming lessons to my classmates.</p>
<p>And I did.</p>
<p>Not just by repeating information but by simplifying it, structuring it, and making it understandable. That moment I discovered that learning deeply allows you to teach clearly.</p>
<p>And then came a new and powerful phase: building the engineering mindset.</p>
<p>At this stage, everything started to come together. It was about thinking differently.</p>
<p>An engineering mindset built on:</p>
<ul>
<li><p>Strong logical foundations</p>
</li>
<li><p>Real project experience</p>
</li>
<li><p>Understanding how systems behave, not just how code runs</p>
</li>
</ul>
<p>And this introduced me to the upcoming certifications.</p>
<h2 id="heading-9-quality-assurance"><strong>9: Quality Assurance</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/e3e7e442-342a-4495-844b-13aaa439a126.png" alt="e3e7e442-342a-4495-844b-13aaa439a126" style="display:block;margin:0 auto" width="1320" height="880" loading="lazy">

<p>I spent time learning how to write code that's not only functional but reliable, maintainable, and scalable.</p>
<p>Using tools, and practices like Chai.js, I began to:</p>
<ul>
<li><p>Test applications properly</p>
</li>
<li><p>Catch errors early</p>
</li>
<li><p>Ensure systems run smoothly under different conditions</p>
</li>
</ul>
<p>And this is where the real transformation started happening. I started moving from being someone who writes code to someone who builds systems.</p>
<h2 id="heading-10-information-security"><strong>10: Information Security</strong></h2>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/424fd621-e6a2-45fb-9ba2-759ddc72ba12.png" alt="424fd621-e6a2-45fb-9ba2-759ddc72ba12" style="display:block;margin:0 auto" width="1315" height="877" loading="lazy">

<p>Through the cybersecurity path on freeCodeCamp, I was introduced to a completely new dimension of software development: thinking about protecting systems, not only building them blindly.</p>
<p>I picked up essential concepts and practical skills using tools like:</p>
<ul>
<li><p>Helmet.js to secure web applications</p>
</li>
<li><p>Python for penetration testing and security analysis</p>
</li>
<li><p>Socket.IO for handling real-time interactions securely</p>
</li>
</ul>
<p>As part of this path, I worked on building five projects including a password cracker. It wasn’t just a technical exercise –&nbsp;it was a way to develop a real security mindset. To understand vulnerabilities, risks, and how attackers think so you can build systems that are stronger and safer.</p>
<p>Then I got into the legacy learning courses:</p>
<h2 id="heading-11-legacy-certifications"><strong>11: Legacy Certifications</strong></h2>
<h3 id="heading-front-end">Front End:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/4d9a6031-969b-43d9-9233-a79ca7768276.png" alt="4d9a6031-969b-43d9-9233-a79ca7768276" style="display:block;margin:0 auto" width="1333" height="903" loading="lazy">

<h3 id="heading-back-end">Back End:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/376f3c22-faf9-4993-8992-56cd37ce9f37.png" alt="376f3c22-faf9-4993-8992-56cd37ce9f37" style="display:block;margin:0 auto" width="1352" height="901" loading="lazy">

<h3 id="heading-data-visualization">Data Visualization:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/88688464-913f-4666-a507-0f15343256a2.png" alt="88688464-913f-4666-a507-0f15343256a2" style="display:block;margin:0 auto" width="1313" height="877" loading="lazy">

<h3 id="heading-full-stack">Full Stack:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/6e47be72-babe-44fa-8249-265b7dcfe9be.png" alt="6e47be72-babe-44fa-8249-265b7dcfe9be" style="display:block;margin:0 auto" width="1332" height="902" loading="lazy">

<h3 id="heading-legacy-cybersecurity-amp-quality-assurance">Legacy Cybersecurity &amp; Quality Assurance:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/69ce92860ff860b6de01ed93/408a7525-e89d-4090-b317-844c1b6ef954.png" alt="408a7525-e89d-4090-b317-844c1b6ef954" style="display:block;margin:0 auto" width="1325" height="885" loading="lazy">

<p>This phase was incredibly valuable.</p>
<p>It felt like a consolidation of everything I had learned, a chance to revisit key concepts with more maturity and deeper understanding. These certifications focused more on what truly matters in each path, with diverse and practical projects that strengthened both my skills and confidence.</p>
<p>If I had to summarize this entire journey in one idea, it would be this: learning by building changes everything.</p>
<p>This core methodology of freeCodeCamp enabled me to:</p>
<ul>
<li><p>Solve real problems</p>
</li>
<li><p>Build actual products</p>
</li>
<li><p>Connect learning with real-world impact</p>
</li>
</ul>
<p>It moved me beyond theory into practice.</p>
<h2 id="heading-personal-recommendations">Personal Recommendations</h2>
<p>Based on my experience, I strongly recommend freeCodeCamp to anyone who wants to:</p>
<ul>
<li><p>Develop programming skills</p>
</li>
<li><p>Strengthen logical thinking</p>
</li>
<li><p>Improve problem-solving ability</p>
</li>
<li><p>Build real-world applications</p>
</li>
</ul>
<p>Because when learning is built on the right methodology, the results are not just visible they are transformative.</p>
<p>Here are <a href="https://programming-ocean.com/knowledge-hub/freecodecamp-atlas.php">resources</a> about freeCodeCamp programs and certifications that structured my learning journey.</p>
<h3 id="heading-contact-me">Contact Me:</h3>
<p><a href="https://github.com/MOHAMMEDFAHD"><strong>GitHub</strong></a></p>
<p><a href="https://www.linkedin.com/in/mohammed-abrah-6435a63ba/"><strong>Linkedin</strong></a></p>
<p><a href="https://x.com/programmingoce"><strong>X</strong></a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Split PDF Files in the Browser Using JavaScript (Step-by-Step) ]]>
                </title>
                <description>
                    <![CDATA[ Working with PDFs is part of everyday development. Sometimes you don’t need the entire document. You just need a few pages — maybe a specific section, a report summary, or selected invoice pages. Most ]]>
                </description>
                <link>https://www.freecodecamp.org/news/split-pdf-files-using-javascript/</link>
                <guid isPermaLink="false">69ef7279330a1ad7f7ec9a85</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Frontend Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Mon, 27 Apr 2026 14:28:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ff8ed4f5-a0f1-44cd-8703-a6dcd95e6b0f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Working with PDFs is part of everyday development.</p>
<p>Sometimes you don’t need the entire document. You just need a few pages — maybe a specific section, a report summary, or selected invoice pages.</p>
<p>Most tools require uploading files or installing software. But modern browsers are powerful enough to handle this locally.</p>
<p>In this tutorial, you’ll learn how to build a browser-based PDF splitter using JavaScript, where everything runs directly in the user’s browser.</p>
<p>By the end, you’ll understand how to extract specific pages from a PDF, create a new document from those pages, and download the result instantly.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/1a0d3489-5034-4aed-add4-68bada614c8e.png" alt="split pdf files,extract pages" style="display:block;margin:0 auto" width="1456" height="267" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-pdf-splitting-works-in-the-browser">How PDF Splitting Works in the Browser</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-what-library-are-we-using">What Library Are We Using?</a></p>
</li>
<li><p><a href="#heading-creating-the-upload-interface">Creating the Upload Interface</a></p>
</li>
<li><p><a href="#heading-reading-the-pdf-file">Reading the PDF File</a></p>
</li>
<li><p><a href="#heading-selecting-pages-to-extract-or-split">Selecting Pages to Extract or Split</a></p>
</li>
<li><p><a href="#heading-splitting-the-pdf-using-javascript">Splitting the PDF Using JavaScript</a></p>
</li>
<li><p><a href="#generating-and-downloading-the-pdf">Generating and Downloading the PDF</a></p>
</li>
<li><p><a href="#demo-how-the-pdf-split-tool-works">Demo: How the PDF Split Tool Works</a></p>
</li>
<li><p><a href="#important-notes-from-real-world-use">Important Notes from Real-World Use</a></p>
</li>
<li><p><a href="#common-mistakes-to-avoid">Common Mistakes to Avoid</a></p>
</li>
<li><p><a href="#conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-how-pdf-splitting-works-in-the-browser">How PDF Splitting Works in the Browser</h2>
<p>Splitting a PDF means taking a single document and extracting specific pages into a new file.</p>
<p>Traditionally, this kind of processing is handled on a server. But with modern JavaScript libraries like pdf-lib, we can do everything directly in the browser.</p>
<p>The process is straightforward. A user uploads a PDF file, the browser reads it, and we can display a preview of its pages to help users understand what they’re working with. Based on the selected split mode or page input, we then extract only the required pages and copy them into a new PDF document.</p>
<p>All of this happens locally in the browser, which makes the process faster and ensures that user files never leave their device.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>We’ll keep this project simple.</p>
<p>You only need:</p>
<ul>
<li><p>an HTML file</p>
</li>
<li><p>JavaScript</p>
</li>
<li><p>a PDF processing library</p>
</li>
</ul>
<p>No backend or server is required.</p>
<h2 id="heading-what-library-are-we-using">What Library Are We Using?</h2>
<p>We’ll use <strong>pdf-lib</strong>, a lightweight JavaScript library for working with PDFs.</p>
<p>Add it using a CDN:</p>
<pre><code class="language-html">&lt;script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"&gt;&lt;/script&gt;
</code></pre>
<p>This library allows us to:</p>
<ul>
<li><p>load PDFs</p>
</li>
<li><p>copy pages</p>
</li>
<li><p>create new documents</p>
</li>
</ul>
<h2 id="heading-creating-the-upload-interface">Creating the Upload Interface</h2>
<p>Start with a simple file input:</p>
<pre><code class="language-html">&lt;input type="file" id="upload" accept="application/pdf"&gt;
&lt;input type="text" id="pages" placeholder="Enter pages (e.g. 1-3,5)"&gt;
&lt;button onclick="splitPDF()"&gt;Split PDF&lt;/button&gt;

&lt;a id="download" style="display:none;"&gt;Download Split PDF&lt;/a&gt;
</code></pre>
<p>This interface allows users to upload a PDF file, specify which pages they want to extract, and trigger the splitting process with a single click. Once the process is complete, the download link becomes visible so they can save the new PDF.</p>
<h2 id="heading-reading-the-pdf-file">Reading the PDF File</h2>
<p>Now let’s read the uploaded file:</p>
<pre><code class="language-javascript">const fileInput = document.getElementById("upload");

if (!fileInput.files.length) {
  alert("Please upload a PDF file");
  return;
}

const file = fileInput.files[0];
const arrayBuffer = await file.arrayBuffer();
</code></pre>
<p>This converts the file into a format the library can use.</p>
<h2 id="heading-selecting-pages-to-extract-or-split">Selecting Pages to Extract or Split</h2>
<p>Users can control how the PDF is split in multiple ways.</p>
<p>They can manually enter page ranges like <code>1-3,5</code>, which allows precise selection of pages. For example, entering <code>1-3</code> extracts pages 1 to 3, while <code>5</code> selects only page 5.</p>
<p>In addition to manual input, the tool also provides predefined options such as splitting all pages, extracting only even or odd pages, or splitting the document into fixed-size ranges. These options make it easier for users who don’t want to type page ranges manually.</p>
<p>To support manual input, we use a simple parser that converts the user’s input into valid page indexes:</p>
<pre><code class="language-javascript">function parsePages(input, totalPages) {
  const pages = [];

  input.split(',').forEach(part =&gt; {
    if (part.includes('-')) {
      const [start, end] = part.split('-').map(Number);
      for (let i = start; i &lt;= end; i++) {
        if (i &lt;= totalPages) pages.push(i - 1);
      }
    } else {
      const num = parseInt(part);
      if (num &lt;= totalPages) pages.push(num - 1);
    }
  });

  return pages;
}
</code></pre>
<p>This approach gives flexibility, allowing both simple and advanced ways to select pages depending on the user’s needs.</p>
<h2 id="heading-splitting-the-pdf-using-javascript">Splitting the PDF Using JavaScript</h2>
<p>Now comes the main logic:</p>
<pre><code class="language-javascript">async function splitPDF() {
  const fileInput = document.getElementById("upload");
  const pageInput = document.getElementById("pages").value;

  if (!fileInput.files.length || !pageInput.trim()) {
    alert("Please upload a PDF and enter page numbers");
    return;
  }

  const file = fileInput.files[0];
  const arrayBuffer = await file.arrayBuffer();

  const { PDFDocument } = PDFLib;

  const originalPdf = await PDFDocument.load(arrayBuffer);
  const totalPages = originalPdf.getPageCount();

  const selectedPages = parsePages(pageInput, totalPages);

  const newPdf = await PDFDocument.create();

  const copiedPages = await newPdf.copyPages(originalPdf, selectedPages);

  copiedPages.forEach(page =&gt; newPdf.addPage(page));

  const pdfBytes = await newPdf.save();

  const blob = new Blob([pdfBytes], { type: "application/pdf" });

  const link = document.getElementById("download");
  link.href = URL.createObjectURL(blob);
  link.download = "split.pdf";
  link.style.display = "inline";
  link.innerText = "Download Split PDF";
}
</code></pre>
<p>This:</p>
<ul>
<li><p>loads the original file</p>
</li>
<li><p>extracts selected pages</p>
</li>
<li><p>creates a new PDF</p>
</li>
<li><p>prepares it for download</p>
</li>
</ul>
<h2 id="heading-generating-and-downloading-the-pdf">Generating and Downloading the PDF</h2>
<p>Once the PDF is created:</p>
<pre><code class="language-javascript">link.href = URL.createObjectURL(blob);
link.download = "split.pdf";
</code></pre>
<p>The browser handles the download instantly — no server needed.</p>
<h2 id="heading-demo-how-the-pdf-split-tool-works">Demo: How the PDF Split Tool Works</h2>
<p>Here’s how the full flow looks in practice using the tool:</p>
<h3 id="heading-step-1-upload-your-pdf">Step 1: Upload Your PDF</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/59361d9e-1f64-428e-8098-49b0976bd3ae.png" alt="PDF splitter tool interface showing drag and drop upload area with select PDF button" style="display:block;margin:0 auto" width="1724" height="757" loading="lazy">

<p>Start by dragging and dropping your PDF file into the upload area, or click the button to select a file from your device. Once uploaded, the tool instantly processes the document and prepares it for splitting.</p>
<h3 id="heading-step-2-preview-pages">Step 2: Preview Pages</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/b6cae555-6b9d-4ea9-afe5-877803574ceb.png" alt="PDF splitter preview showing multiple pages as thumbnails for visual selection" style="display:block;margin:0 auto" width="1408" height="430" loading="lazy">

<p>After uploading, all pages of the PDF are displayed as thumbnails. This gives you a clear visual overview of the document so you can decide how you want to split it.</p>
<h3 id="heading-step-3-choose-split-mode-and-options">Step 3: Choose Split Mode and Options</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/e7ee8c00-8247-41ce-9459-4de7f5e8b1ef.png" alt="PDF splitter settings with options for page range, all pages, fixed range, and odd or even page splitting" style="display:block;margin:0 auto" width="1753" height="523" loading="lazy">

<p>Next, choose how you want to split the PDF. You can select options like splitting by page range, extracting all pages, splitting odd or even pages, or dividing the document into fixed-size sections. This flexibility makes it easy to handle different use cases without manually selecting every page.</p>
<h3 id="heading-step-4-split-the-pdf">Step 4: Split the PDF</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/185b297f-f16e-4269-a885-6dd48903db23.png" alt="PDF splitter interface showing split PDF button and start over option" style="display:block;margin:0 auto" width="1644" height="195" loading="lazy">

<p>Once your settings are ready, click the split button. The browser processes the file locally and generates the new PDFs based on your selected mode.</p>
<h3 id="heading-step-5-download-the-results">Step 5: Download the Results</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/c3fdf474-9052-4661-9c82-c34515a4423c.png" alt="PDF splitter result showing multiple generated files with download buttons and download all option" style="display:block;margin:0 auto" width="967" height="636" loading="lazy">

<p>After processing, the split files are displayed with download options. You can download individual files or download all of them at once. Everything happens instantly in the browser without uploading your files anywhere.</p>
<h2 id="heading-important-notes-from-real-world-use">Important Notes from Real-World Use</h2>
<p>When working with PDF splitting, input validation is important.</p>
<p>Users may enter invalid ranges or page numbers that don’t exist. Always validate and limit input to available pages.</p>
<p>Handling large PDFs can also affect performance. Instead of processing everything at once, you can handle operations step by step to keep the browser responsive.</p>
<p>Another key consideration is privacy. Since all processing happens in the browser, files never leave the user’s device. This makes the tool safer for sensitive documents.</p>
<p>In real-world applications, it’s important to clearly communicate that files are not uploaded or stored anywhere.</p>
<h2 id="heading-common-mistakes-to-avoid">Common Mistakes to Avoid</h2>
<p>One common issue is not validating user input. If users enter incorrect page ranges, the tool may fail or produce unexpected results.</p>
<p>Another mistake is forgetting that page indexes start at zero internally. If you don’t adjust for this, you may extract the wrong pages.</p>
<p>Also, skipping edge cases like empty input or large files can make the tool unreliable.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF splitter using JavaScript.</p>
<p>You learned how to read PDF files, extract specific pages, and generate a new document entirely in the browser.</p>
<p>This approach removes the need for a backend and keeps everything fast and private.</p>
<p>If you’d like to see a complete working version of this idea, you can try it here: <a href="https://allinonetools.net/split-pdf/">Split PDF</a></p>
<p>Once you understand this pattern, you can extend it further to build more advanced PDF tools like merging, compression, or editing.</p>
<p>And that’s where things start getting really interesting.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Merge PDF Files in the Browser Using JavaScript (Step-by-Step)  ]]>
                </title>
                <description>
                    <![CDATA[ Working with PDFs is something almost every developer needs to know how to do. Sometimes you need to combine reports or invoices, or simply merge multiple documents into a single clean file. Most tool ]]>
                </description>
                <link>https://www.freecodecamp.org/news/merge-pdf-files-using-javascript/</link>
                <guid isPermaLink="false">69e8f8f6bca83cce6c55bcdf</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ webdev ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Wed, 22 Apr 2026 16:36:06 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/abc987c9-4748-45ac-89da-2bce035c830f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Working with PDFs is something almost every developer needs to know how to do.</p>
<p>Sometimes you need to combine reports or invoices, or simply merge multiple documents into a single clean file.</p>
<p>Most tools that handle this either require installing software or uploading files to a server, which can be slow and not always ideal – especially when dealing with private documents.</p>
<p>But what if you could merge PDFs directly in the browser, without any backend?</p>
<p>That’s exactly what we’ll build in this tutorial.</p>
<p>By the end, you’ll have a fully working browser-based PDF merger. It will allow users to upload files, preview them, reorder documents using drag-and-drop, select specific pages, and download the final merged PDF instantly.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/63f94f41-07a3-4c70-b30d-cd60756efba1.png" alt="Browser-based PDF merger tool with drag-and-drop upload interface" style="display:block;margin:0 auto" width="1508" height="272" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-pdf-merging-works-in-the-browser">How PDF Merging Works in the Browser</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-what-library-are-we-using">What Library Are We Using?</a></p>
</li>
<li><p><a href="#heading-creating-the-upload-interface">Creating the Upload Interface</a></p>
</li>
<li><p><a href="#heading-rendering-pdf-previews">Rendering PDF Previews</a></p>
</li>
<li><p><a href="#heading-reordering-files-drag-and-drop">Reordering Files Drag and Drop</a></p>
</li>
<li><p><a href="#heading-sorting-and-reordering-pdfs-important">Sorting and Reordering PDFs (Important)</a></p>
</li>
<li><p><a href="#heading-merging-pdfs-using-javascript">Merging PDFs Using JavaScript</a></p>
</li>
<li><p><a href="#heading-improving-user-experience">Improving User Experience</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-merger-works">Demo: How the PDF Merger Works</a></p>
</li>
<li><p><a href="#heading-important-notes-from-real-world-use">Important Notes from Real-World Use</a></p>
</li>
<li><p><a href="#heading-common-mistakes-to-avoid">Common Mistakes to Avoid</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-how-pdf-merging-works-in-the-browser">How PDF Merging Works in the Browser</h2>
<p>At a high level, merging PDFs means loading multiple PDF files, extracting pages from each, and combining them into a single document.</p>
<p>Traditionally, this process happens on a server. Files are uploaded, processed, and then returned to the user.</p>
<p>But modern JavaScript libraries make it possible to do all of this directly in the browser. Instead of sending files anywhere, the entire process runs locally on the user’s device.</p>
<p>This approach has a few practical advantages. It makes the process faster because there’s no upload time involved. It also improves privacy, since files never leave the user’s system. And from a development perspective, it removes the need for backend processing altogether.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>We’ll keep this project simple.</p>
<p>You only need:</p>
<ul>
<li><p>an HTML file</p>
</li>
<li><p>JavaScript</p>
</li>
<li><p>a few libraries</p>
</li>
</ul>
<p>No backend required.</p>
<h2 id="heading-what-library-are-we-using">What Library Are We Using?</h2>
<p>We’ll use two important libraries:</p>
<pre><code class="language-html">&lt;script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"&gt;&lt;/script&gt;
&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js"&gt;&lt;/script&gt;
</code></pre>
<ul>
<li><p>We'll use <strong>pdf-lib</strong> to merge and modify PDFs</p>
</li>
<li><p>We'll use <strong>pdf.js</strong> to render previews in the browser</p>
</li>
</ul>
<p>This combination is very powerful and commonly used in real projects.</p>
<h2 id="heading-creating-the-upload-interface">Creating the Upload Interface</h2>
<p>Start with a simple drag-and-drop area:</p>
<pre><code class="language-html">&lt;div id="upload-area"&gt;
  &lt;input type="file" id="file-input" multiple accept="application/pdf"&gt;
&lt;/div&gt;
</code></pre>
<p>Users can either drag files or click to select.</p>
<p>Once files are selected, we read them using:</p>
<pre><code class="language-JavaScript">const arrayBuffer = await file.arrayBuffer();
</code></pre>
<p>This allows us to pass the file into our PDF libraries.</p>
<h2 id="heading-rendering-pdf-previews">Rendering PDF Previews</h2>
<p>To improve usability, we'll show a preview of each uploaded PDF.</p>
<p>Using <strong>pdf.js</strong>, we can render pages like this:</p>
<pre><code class="language-js">const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
const page = await pdf.getPage(1);

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

page.render({
  canvasContext: context,
  viewport: viewport
});
</code></pre>
<p>This gives users visual feedback before merging.</p>
<h2 id="heading-reordering-files-drag-and-drop">Reordering Files (Drag and Drop)</h2>
<p>Order matters when merging PDFs.</p>
<p>Instead of forcing users to upload in sequence, we'll allow reordering.</p>
<p>We can use a library like <strong>Sortable.js</strong> for this:</p>
<pre><code class="language-js">new Sortable(document.getElementById('pdf-grid'), {
  animation: 150
});
</code></pre>
<p>This enables drag-and-drop sorting and instant visual updates.</p>
<h2 id="heading-sorting-and-reordering-pdfs-important">Sorting and Reordering PDFs (Important)</h2>
<p>This is where the tool becomes more practical in real-world use.</p>
<p>Instead of forcing users to upload files in a specific order, the tool allows them to rearrange PDFs before merging.</p>
<p>Users can manually drag and drop files to adjust the sequence, or use built-in sorting options such as arranging files alphabetically or by file size. This makes it easy to quickly organize multiple documents without re-uploading them.</p>
<p>This flexibility ensures that the final merged document follows the exact order the user needs. In real-world scenarios, this is especially useful when combining reports, invoices, or other documents where sequence is important.</p>
<p>Here’s a simple example of how you might sort uploaded files:</p>
<pre><code class="language-javascript">function sortFiles(files, type) {
  return files.sort((a, b) =&gt; {
    if (type === "name-asc") {
      return a.name.localeCompare(b.name);
    }

    if (type === "name-desc") {
      return b.name.localeCompare(a.name);
    }

    if (type === "size-asc") {
      return a.size - b.size;
    }

    if (type === "size-desc") {
      return b.size - a.size;
    }

    return 0;
  });
}
</code></pre>
<p>This allows precise control over what gets merged.</p>
<h2 id="heading-merging-pdfs-using-javascript">Merging PDFs Using JavaScript</h2>
<p>Now comes the core logic. We'll use <strong>pdf-lib</strong> to combine pages:</p>
<pre><code class="language-js">const { PDFDocument } = PDFLib;

const mergedPdf = await PDFDocument.create();

for (const file of files) {
  const pdf = await PDFDocument.load(file.arrayBuffer);
  const pages = await mergedPdf.copyPages(pdf, selectedPages);

  pages.forEach(page =&gt; mergedPdf.addPage(page));
}

const pdfBytes = await mergedPdf.save();
</code></pre>
<p>Finally, we'll create a downloadable file:</p>
<pre><code class="language-js">const blob = new Blob([pdfBytes], { type: 'application/pdf' });
</code></pre>
<h2 id="heading-improving-user-experience">Improving User Experience</h2>
<p>A simple merge tool works, but a good tool feels smooth.</p>
<p>Small improvements make a big difference.</p>
<p>For example:</p>
<ul>
<li><p>showing previews before merging</p>
</li>
<li><p>allowing users to remove files</p>
</li>
<li><p>enabling page navigation</p>
</li>
<li><p>providing instant feedback</p>
</li>
</ul>
<p>These details turn a basic feature into a real product.</p>
<h2 id="heading-demo-how-the-pdf-merger-works">Demo: How the PDF Merger Works</h2>
<p>Here’s how the full flow looks in practice:</p>
<h3 id="heading-step-1-upload-pdfs">Step 1: Upload PDFs</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/f7b544ed-e1df-40c2-a2bd-c9245850d7b5.png" alt="PDF merger tool interface showing drag and drop upload area with select files button" style="display:block;margin:0 auto" width="1741" height="806" loading="lazy">

<p>Users can drag and drop PDF files into the upload area or select them manually.</p>
<h3 id="heading-step-2-preview-files">Step 2: Preview Files</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/a60a38b9-d535-4856-afbf-3a9ccb427d2d.png" alt="Preview of uploaded PDF files showing document thumbnails and file details before merging" style="display:block;margin:0 auto" width="1383" height="707" loading="lazy">

<p>Each uploaded file is displayed with a preview as well as pdf files details (name, size, nos of page, and so on), so users can verify the content before merging.</p>
<h3 id="heading-step-3-reorder-files">Step 3: Reorder Files</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/6c93b3da-3857-4760-a64b-87edc739178e.png" alt="PDF sorting options interface showing manual order and sorting by name or file size" style="display:block;margin:0 auto" width="528" height="356" loading="lazy">

<p>Users can arrange the order of PDFs using drag-and-drop or sorting options as well as manual options. This ensures the final merged document follows the correct sequence.</p>
<h3 id="heading-step-4-merge-pdfs">Step 4: Merge PDFs</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/5b8f22ab-ca44-4686-9c3a-647983e6ae08.png" alt="Merge PDFs button used to combine multiple PDF files into a single document" style="display:block;margin:0 auto" width="379" height="220" loading="lazy">

<p>Once everything is arranged, users can click the merge button to combine all selected PDFs into a single file.</p>
<h3 id="heading-step-5-download-the-final-pdf">Step 5: Download the Final PDF</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/d55d61d9-ac46-4c22-8f64-885a87d693cc.png" alt="Merged PDF preview with file details and download button after combining documents" style="display:block;margin:0 auto" width="1367" height="660" loading="lazy">

<p>The merged PDF is generated instantly in the browser, and users can preview , rename, and download it without any server interaction.</p>
<h2 id="heading-important-notes-from-real-world-use">Important Notes from Real-World Use</h2>
<p>When building tools like a PDF merger, handling large files efficiently becomes important.</p>
<p>If multiple large PDFs are loaded at once, it can slow down the browser or consume too much memory. Instead of processing everything at once, it’s better to handle files step by step.</p>
<p>For example, instead of loading all PDFs together, you can process them one by one:</p>
<pre><code class="language-javascript">const { PDFDocument } = PDFLib;

const mergedPdf = await PDFDocument.create();

for (const file of files) {
  const arrayBuffer = await file.arrayBuffer();
  const pdf = await PDFDocument.load(arrayBuffer);

  const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());

  pages.forEach(page =&gt; mergedPdf.addPage(page));
}
</code></pre>
<p>This approach keeps memory usage lower and avoids freezing the browser when working with larger files.</p>
<p>You can also improve performance by limiting file size or the number of files users can upload at once. This helps keep the tool responsive even on lower-powered devices.</p>
<p>Another important aspect is privacy. Since everything runs directly in the browser, files are never uploaded to a server. This means sensitive documents stay on the user’s device.</p>
<p>But it’s still important to be transparent about this. In real-world tools, you should clearly mention that all processing happens locally and no files are stored or transmitted.</p>
<p>This client-side approach improves both performance and user trust, especially when working with private or confidential documents.</p>
<h2 id="heading-common-mistakes-to-avoid">Common Mistakes to Avoid</h2>
<p>A common mistake is skipping validation. If users upload invalid files or empty inputs, the merge process can fail.</p>
<p>Another issue is ignoring page ranges. If parsing is incorrect, users may get unexpected results.</p>
<p>Also, relying on fixed layouts or assumptions can break the experience across different files. Testing with different PDF types is important.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a browser-based PDF merger using JavaScript.</p>
<p>More importantly, you learned how to process files locally in the browser, render previews for better usability, handle user input safely, and manage dynamic document structures when working with PDFs.</p>
<p>This approach removes the need for a backend and keeps everything fast, private, and efficient.</p>
<p>Once you understand this pattern, you can extend it to build more advanced tools. For example, you could create features like PDF splitting, compression, editing, or other document-based utilities using the same core ideas.</p>
<p>And that’s where things start getting really interesting.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Automatic Knowledge Graph for Your Blog with PHP and JSON-LD ]]>
                </title>
                <description>
                    <![CDATA[ When someone searches for information today, they increasingly turn to AI models like ChatGPT, Perplexity, or Gemini instead of Google. But these models don't return a list of links. They synthesize a ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-automatic-knowledge-graph-php-json-ld/</link>
                <guid isPermaLink="false">69e80305e436727814adb8df</guid>
                
                    <category>
                        <![CDATA[ PHP ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JSON-LD ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SEO ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shinobis ]]>
                </dc:creator>
                <pubDate>Tue, 21 Apr 2026 23:06:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/397b339f-25e0-48f6-b3fc-07f0548be746.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>When someone searches for information today, they increasingly turn to AI models like ChatGPT, Perplexity, or Gemini instead of Google. But these models don't return a list of links. They synthesize an answer and cite the sources they trust most.</p>
<p>The question for anyone who runs a blog or content site is: how do you become one of those trusted sources? The answer lies in structured data, specifically JSON-LD Knowledge Graphs that help AI models understand not just what your content says, but how it connects to everything else you've published.</p>
<p>In this tutorial, you'll build a PHP function that auto-generates a JSON-LD Knowledge Graph for every blog post on your site. There are no plugins, no external APIs, and just one function. It will detect entities in your content, map relationships between posts, and output a unified schema that both Google and AI models like ChatGPT can parse as a connected system.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-this-matters-now">Why This Matters Now</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-pipeline">The Pipeline</a></p>
</li>
<li><p><a href="#heading-what-static-json-ld-looks-like-and-why-it-falls-short">What Static JSON-LD Looks Like (And Why It Falls Short)</a></p>
</li>
<li><p><a href="#heading-step-1-define-your-entity-helpers">Step 1: Define Your Entity Helpers</a></p>
</li>
<li><p><a href="#heading-step-2-build-the-blogposting-schema">Step 2: Build the BlogPosting Schema</a></p>
</li>
<li><p><a href="#heading-step-3-detect-topics-automatically">Step 3: Detect Topics Automatically</a></p>
</li>
<li><p><a href="#heading-step-4-map-relationships-between-posts">Step 4: Map Relationships Between Posts</a></p>
</li>
<li><p><a href="#heading-step-5-add-multilingual-connections">Step 5: Add Multilingual Connections</a></p>
</li>
<li><p><a href="#heading-step-6-assemble-the-graph">Step 6: Assemble the Graph</a></p>
</li>
<li><p><a href="#heading-what-the-output-looks-like-in-production">What the Output Looks Like in Production</a></p>
</li>
<li><p><a href="#heading-testing-your-implementation">Testing Your Implementation</a></p>
</li>
<li><p><a href="#heading-what-i-learned-after-3-months-in-production">What I Learned After 3 Months in Production</a></p>
</li>
</ul>
<h3 id="heading-why-this-matters-now">Why This Matters Now</h3>
<p>AI search engines are replacing blue links with synthesized answers. When someone asks ChatGPT a question, it doesn't return a list of URLs. It builds a response by citing the sources it trusts.</p>
<p><a href="https://www.accuracast.com/articles/optimisation/schema-markup-impact-ai-search/">According to AccuraCast's research on AI search citations</a>, 81% of pages cited by AI engines use schema markup with JSON-LD as the dominant format. Pages with structured schema are 3 to 4 times more likely to be cited by ChatGPT or Perplexity than pages without it.</p>
<p>Most JSON-LD tutorials teach you to paste a static <code>&lt;script&gt;</code> tag with your title and author name. That gets you into Google's index. But it doesn't get you cited by AI.</p>
<p>For that, you need a Knowledge Graph: a system where your entities (author, site, topics, tools, related articles) are connected through persistent identifiers that machines can follow across every page on your site.</p>
<p>I built this system for my own blog. After three months in production with 52 posts in three languages, I asked ChatGPT, Gemini, and Perplexity to audit the resulting schema. ChatGPT scored it 9.1 out of 10 and called it "production-grade graph design." This article walks you through how to build the same thing.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow this tutorial, you'll need:</p>
<ul>
<li><p>PHP 7.4 or higher running on your server</p>
</li>
<li><p>A MySQL or MariaDB database with a posts table that stores your blog content (title, slug, content, excerpt, created_at, updated_at)</p>
</li>
<li><p>Basic PHP knowledge: variables, arrays, functions, and database queries with PDO</p>
</li>
<li><p>A working blog where you can edit PHP files and add schema markup to your HTML output</p>
</li>
</ul>
<p>The tools we'll use are all built into PHP. No external packages or Composer dependencies are required. The entity detection uses simple string matching with strpos(), the database queries use PDO prepared statements, and the JSON-LD output uses PHP's native json_encode(). If you've built a blog with PHP before, you have everything you need.</p>
<h2 id="heading-the-pipeline">The Pipeline</h2>
<p>The system works in four stages:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69b380d893256dfc53256f05/58404ce5-4603-4095-930f-761cb72c8e95.png" alt="Diagram showing the four-stage pipeline: Post from Database to Entity Detection to Relationship Mapping to @graph Output" style="display:block;margin:0 auto" width="900" height="505" loading="lazy">

<p><strong>Stage 1</strong>: PHP queries MariaDB for the post content, metadata, and related post IDs.</p>
<p><strong>Stage 2</strong>: The system scans the content for known topics and tools using keyword matching. No NLP libraries needed. A simple associative array maps keywords to schema entities.</p>
<p><strong>Stage 3</strong>: Related posts are fetched and mapped as both navigation links (<code>relatedLink</code>) and knowledge relationships (<code>citation</code>).</p>
<p><strong>Stage 4</strong>: Everything gets combined into a single <code>@graph</code> array with five connected entities: WebSite, Organization, Person, WebPage, and BlogPosting. Each entity has a stable <code>@id</code> that machines can reference across pages.</p>
<h2 id="heading-what-static-json-ld-looks-like-and-why-it-falls-short">What Static JSON-LD Looks Like (And Why It Falls Short)</h2>
<p>Here is what a typical tutorial tells you to add:</p>
<pre><code class="language-json">{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "My Blog Post",
  "author": {
    "@type": "Person",
    "name": "Jane"
  },
  "datePublished": "2026-01-15"
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69b380d893256dfc53256f05/d2e717e6-ad9c-4267-955f-d9c27b6b43a7.png" alt="Comparison between a minimal static JSON-LD schema and a full Knowledge Graph with five connected entities" style="display:block;margin:0 auto" width="900" height="600" loading="lazy">

<p>This tells Google "there is an article by Jane." It doesn't say what topics the article covers, what tools it mentions, how it connects to other articles on your site, who publishes the site, or what makes Jane an authority on the subject.</p>
<p>For a blog with dozens of posts about interconnected topics, every post exists in isolation. Search engines and AI models can't see that your articles form a system of knowledge. They can't tell that your post about Midjourney prompts connects to your post about AI design workflows, which connects to your post about fintech UX.</p>
<p>By the end of this tutorial, that same post will generate a <code>@graph</code> with five linked entities, automatic topic detection, relationship mapping, multilingual connections, and an abstract that LLMs read before deciding whether to cite you.</p>
<h2 id="heading-step-1-define-your-entity-helpers">Step 1: Define Your Entity Helpers</h2>
<p>Three PHP functions define your core entities. They return arrays that get reused on every page of your site.</p>
<pre><code class="language-php">function getSchemaAuthor($baseUrl) {
    return [
        '@type' =&gt; 'Person',
        '@id' =&gt; $baseUrl . '/#author',
        'name' =&gt; 'Your Name',
        'description' =&gt; 'Your professional description.',
        'url' =&gt; $baseUrl . '/about',
        'image' =&gt; $baseUrl . '/photo.png',
        'jobTitle' =&gt; 'Your Title',
        'sameAs' =&gt; [
            'https://linkedin.com/in/yourprofile',
            'https://x.com/yourhandle',
            'https://dev.to/yourprofile'
        ]
    ];
}

function getSchemaOrganization($baseUrl) {
    return [
        '@type' =&gt; 'Organization',
        '@id' =&gt; $baseUrl . '/#organization',
        'name' =&gt; 'Your Site Name',
        'url' =&gt; $baseUrl,
        'logo' =&gt; [
            '@type' =&gt; 'ImageObject',
            'url' =&gt; $baseUrl . '/logo.png'
        ]
    ];
}

function getSchemaWebSite(\(baseUrl, \)siteName, \(siteDesc, \)langCode) {
    return [
        '@type' =&gt; 'WebSite',
        '@id' =&gt; $baseUrl . '/#website',
        'name' =&gt; $siteName,
        'description' =&gt; $siteDesc,
        'url' =&gt; $baseUrl,
        'inLanguage' =&gt; $langCode,
        'publisher' =&gt; ['@id' =&gt; $baseUrl . '/#organization']
    ];
}
</code></pre>
<p>The <code>@id</code> values are the most important detail. <code>/#author</code>, <code>/#organization</code>, and <code>/#website</code> are persistent identifiers that stay the same across every page.</p>
<p>When a machine reads your homepage and then reads a blog post, it recognizes that <code>https://yoursite.com/#author</code> is the same entity in both places. Without <code>@id</code>, each page creates a new floating entity that machines can't connect.</p>
<p>One decision that matters: the <code>publisher</code> should be an Organization, not a Person. AI systems assign more trust to content published by organizations than by individuals. Even if you're a solo creator, define your site as an Organization for publishing purposes and keep yourself as the Person author.</p>
<h2 id="heading-step-2-build-the-blogposting-schema">Step 2: Build the BlogPosting Schema</h2>
<p>This function takes a post from your database and the current language code, then builds the core BlogPosting entity.</p>
<pre><code class="language-php">function generateBlogPostingSchema(\(post, \)langCode) {
    $baseUrl = rtrim(SITE_URL, '/');
    \(siteName = getLocalizedSetting('site_name', \)langCode);
    \(siteDesc = getLocalizedSetting('site_description', \)langCode);
    $defaultLang = getDefaultLanguage();
    \(postSlug = \)post['slug'];

    \(postUrl = \)langCode === $defaultLang
        ? \(baseUrl . '/' . \)postSlug
        : \(baseUrl . '/' . \)langCode . '/' . $postSlug;

    \(excerpt = \)post['excerpt']
        ?: mb_substr(strip_tags($post['content']), 0, 160);

    $blogPosting = [
        '@type' =&gt; 'BlogPosting',
        '@id' =&gt; $postUrl . '#article',
        'headline' =&gt; $post['title'],
        'description' =&gt; $excerpt,
        'abstract' =&gt; $excerpt,
        'url' =&gt; $postUrl,
        'datePublished' =&gt; date('c', strtotime($post['created_at'])),
        'dateModified' =&gt; date('c', strtotime($post['updated_at'])),
        'author' =&gt; [
            '@type' =&gt; 'Person',
            '@id' =&gt; $baseUrl . '/#author',
            'name' =&gt; 'Your Name',
            'url' =&gt; $baseUrl . '/about'
        ],
        'publisher' =&gt; [
            '@type' =&gt; 'Organization',
            '@id' =&gt; $baseUrl . '/#organization',
            'name' =&gt; 'Your Site Name',
            'logo' =&gt; [
                '@type' =&gt; 'ImageObject',
                'url' =&gt; $baseUrl . '/logo.png'
            ]
        ],
        'isPartOf' =&gt; ['@id' =&gt; $baseUrl . '/#website'],
        'mainEntityOfPage' =&gt; [
            '@type' =&gt; 'WebPage',
            '@id' =&gt; $postUrl
        ],
        'inLanguage' =&gt; $langCode,
        'wordCount' =&gt; str_word_count(strip_tags($post['content']))
    ];
</code></pre>
<p>Two properties deserve attention.</p>
<p><code>abstract</code> maps the post excerpt. LLMs read the abstract first to decide whether the rest of the page is worth processing. If your excerpt says "In this post I explore some ideas about..." models may skip you entirely. Make it a direct statement: "To implement a Knowledge Graph you need five connected entities with persistent @id references." That's something an LLM can evaluate immediately.</p>
<p><code>isPartOf</code> connects the article to the WebSite entity. This tells machines "this article belongs to a larger knowledge source." Without it, each post looks like an independent document.</p>
<p>Notice that <code>author</code> and <code>publisher</code> include both <code>@id</code> and inline properties. The <code>@id</code> connects to the full entity in the <code>@graph</code>. The inline properties are a fallback because some parsers (including Google's Rich Results Test) don't always resolve <code>@id</code> references. Including both ensures zero validation warnings.</p>
<h2 id="heading-step-3-add-automatic-entity-detection">Step 3: Add Automatic Entity Detection</h2>
<p>This is where static JSON-LD tutorials stop and your Knowledge Graph begins. Instead of manually tagging each post with its topics, the system scans the content automatically.</p>
<pre><code class="language-php">    \(contentLower = strtolower(\)post['content'] . ' ' . $post['title']);

    $topicMap = [
        'midjourney'      =&gt; ['name' =&gt; 'Midjourney', 'url' =&gt; 'https://midjourney.com'],
        'prompt'          =&gt; ['name' =&gt; 'Prompt Engineering'],
        'fintech'         =&gt; ['name' =&gt; 'Fintech UX Design'],
        'ux design'       =&gt; ['name' =&gt; 'UX Design'],
        'llms.txt'        =&gt; ['name' =&gt; 'llms.txt', 'url' =&gt; 'https://llmstxt.org'],
        'knowledge graph' =&gt; ['name' =&gt; 'Knowledge Graph'],
    ];

    $aboutItems = [];
    $keywordsList = [];
    foreach (\(topicMap as \)keyword =&gt; $meta) {
        if (strpos(\(contentLower, \)keyword) !== false) {
            \(item = ['@type' =&gt; 'Thing', 'name' =&gt; \)meta['name']];
            if (isset(\(meta['url'])) \)item['url'] = $meta['url'];
            \(aboutItems[] = \)item;
            \(keywordsList[] = \)meta['name'];
        }
    }
    if (!empty($aboutItems)) {
        \(blogPosting['about'] = \)aboutItems;
    }
</code></pre>
<p>The same pattern detects tools mentioned in the content:</p>
<pre><code class="language-php">    $toolMap = [
        'midjourney' =&gt; ['name' =&gt; 'Midjourney', 'url' =&gt; 'https://midjourney.com'],
        'claude'     =&gt; ['name' =&gt; 'Claude', 'url' =&gt; 'https://claude.ai'],
        'chatgpt'    =&gt; ['name' =&gt; 'ChatGPT', 'url' =&gt; 'https://chat.openai.com'],
        'figma'      =&gt; ['name' =&gt; 'Figma', 'url' =&gt; 'https://figma.com'],
    ];

    $mentionItems = [];
    foreach (\(toolMap as \)keyword =&gt; $meta) {
        if (strpos(\(contentLower, \)keyword) !== false) {
            $mentionItems[] = [
                '@type' =&gt; 'Thing',
                'name' =&gt; $meta['name'],
                'url' =&gt; $meta['url']
            ];
            \(keywordsList[] = \)meta['name'];
        }
    }
    if (!empty($mentionItems)) {
        \(blogPosting['mentions'] = \)mentionItems;
    }

    if (!empty($keywordsList)) {
        \(blogPosting['keywords'] = array_values(array_unique(\)keywordsList));
    }
</code></pre>
<p>The difference between <code>about</code> and <code>mentions</code> matters for AI citation. <code>about</code> declares the main topics. <code>mentions</code> declares tools and references that appear in the content. If a post is a Midjourney tutorial that also mentions Claude, <code>about</code> gets Midjourney and <code>mentions</code> gets Claude.</p>
<p>This distinction helps AI models decide whether to cite your page when someone asks about Midjourney versus when they ask about Claude.</p>
<p>A question that comes up often: do you need NLP for entity detection? No. A keyword map with <code>strpos</code> handles the vast majority of cases for a personal blog. NLP adds complexity, latency, and a dependency you don't need. If your topic map has 20 to 30 entries, keyword matching is fast, predictable, and easy to debug.</p>
<h2 id="heading-step-4-map-relationships-between-posts">Step 4: Map Relationships Between Posts</h2>
<p>Each post connects to related posts through two properties: <code>relatedLink</code> for navigation and <code>citation</code> for knowledge relationships.</p>
<pre><code class="language-php">    \(relatedUrls = getRelatedPostUrls(\)post['id'], $langCode);
    if (!empty($relatedUrls)) {
        \(blogPosting['relatedLink'] = \)relatedUrls;
        \(blogPosting['citation'] = \)relatedUrls;
    }
</code></pre>
<p>The helper function queries a <code>post_connections</code> table:</p>
<pre><code class="language-php">function getRelatedPostUrls(\(postId, \)langCode) {
    $pdo = getDB();
    $baseUrl = rtrim(SITE_URL, '/');
    $defaultLang = getDefaultLanguage();

    \(stmt = \)pdo-&gt;prepare(
        "SELECT connected_post_id FROM post_connections WHERE post_id = ?"
    );
    \(stmt-&gt;execute([\)postId]);
    \(connections = \)stmt-&gt;fetchAll(PDO::FETCH_COLUMN);

    $urls = [];
    foreach (\(connections as \)connId) {
        \(slug = getPostSlugForLanguage(\)connId, $langCode);
        if ($slug) {
            \(urls[] = \)langCode === $defaultLang
                ? \(baseUrl . '/' . \)slug
                : \(baseUrl . '/' . \)langCode . '/' . $slug;
        }
    }
    return $urls;
}
</code></pre>
<p>Why use both <code>relatedLink</code> and <code>citation</code> on the same URLs? They signal different things to machines. <code>relatedLink</code> says "the reader might want to visit these pages next." <code>citation</code> says "this article builds on the knowledge in these other articles."</p>
<p>AI models weigh <code>citation</code> more heavily when deciding whether your content is part of a larger knowledge system. Using both tells machines that your related posts aren't just navigation. They're sources this article builds upon.</p>
<h2 id="heading-step-5-add-multilingual-support">Step 5: Add Multilingual Support</h2>
<p>If your blog publishes in multiple languages, <code>workTranslation</code> connects different language versions of the same article.</p>
<pre><code class="language-php">    $languages = getActiveLanguages();
    $translations = [];
    foreach (\(languages as \)lang) {
        \(lc = \)lang['code'];
        if (\(lc === \)langCode) continue;

        \(translatedSlug = getPostSlugForLanguage(\)post['id'], $lc);
        if ($translatedSlug) {
            \(translatedUrl = \)lc === $defaultLang
                ? \(baseUrl . '/' . \)translatedSlug
                : \(baseUrl . '/' . \)lc . '/' . $translatedSlug;

            \(stmtT = \)pdo-&gt;prepare(
                "SELECT title FROM post_translations
                 WHERE post_id = ? AND language_code = ? LIMIT 1"
            );
            \(stmtT-&gt;execute([\)post['id'], $lc]);
            \(translatedTitle = \)stmtT-&gt;fetchColumn() ?: $post['title'];

            $translations[] = [
                '@type' =&gt; 'CreativeWork',
                '@id' =&gt; $translatedUrl . '#article',
                'headline' =&gt; $translatedTitle,
                'url' =&gt; $translatedUrl,
                'inLanguage' =&gt; $lc
            ];
        }
    }
    if (!empty($translations)) {
        \(blogPosting['workTranslation'] = \)translations;
    }
</code></pre>
<p>Without <code>workTranslation</code>, a blog with 50 posts in three languages looks like 150 independent articles to AI models. With it, the same blog looks like 50 pieces of knowledge with multilingual reach. The authority consolidates instead of fragmenting.</p>
<p>The translations use <code>@type: CreativeWork</code> instead of <code>BlogPosting</code>. This avoids warnings in Google's Rich Results Test where each translation would be flagged as a separate article with missing required fields.</p>
<h2 id="heading-step-6-assemble-the-graph">Step 6: Assemble the Graph</h2>
<p>Bring everything together:</p>
<pre><code class="language-php">    $webPage = [
        '@type' =&gt; 'WebPage',
        '@id' =&gt; $postUrl,
        'url' =&gt; $postUrl,
        'name' =&gt; $post['title'],
        'isPartOf' =&gt; ['@id' =&gt; $baseUrl . '/#website']
    ];

    $graph = [
        '@context' =&gt; 'https://schema.org',
        '@graph' =&gt; [
            getSchemaWebSite(\(baseUrl, \)siteName, \(siteDesc, \)langCode),
            getSchemaOrganization($baseUrl),
            getSchemaAuthor($baseUrl),
            $webPage,
            $blogPosting
        ]
    ];

    return '&lt;script type="application/ld+json"&gt;'
        . json_encode($graph,
            JSON_UNESCAPED_SLASHES
            | JSON_UNESCAPED_UNICODE
            | JSON_PRETTY_PRINT)
        . '&lt;/script&gt;';
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69b380d893256dfc53256f05/04e9ab4f-ab33-432f-baf8-b318bbef949f.png" alt="Visual representation of the @graph architecture showing WebSite, Organization, Person, WebPage, and BlogPosting connected via @id references" style="display:block;margin:0 auto" width="803" height="600" loading="lazy">

<p>The <code>json_encode</code> flags matter. <code>JSON_UNESCAPED_SLASHES</code> prevents URLs from getting escaped. <code>JSON_UNESCAPED_UNICODE</code> keeps non-ASCII characters readable for multilingual content. Without these, a single special character in a blog post title fetched from the database can break the entire JSON-LD block silently.</p>
<h2 id="heading-what-the-output-looks-like-in-production">What the Output Looks Like in Production</h2>
<p>Here is the actual JSON-LD generated by a real post on <a href="https://shinobis.com">shinobis.com</a>, a blog about AI tools and UX design:</p>
<pre><code class="language-json">{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebSite",
      "@id": "https://shinobis.com/#website",
      "name": "Designer in the Age of AI",
      "description": "AI tools and real workflows from a designer who builds with AI.",
      "url": "https://shinobis.com",
      "inLanguage": "en",
      "publisher": { "@id": "https://shinobis.com/#organization" }
    },
    {
      "@type": "Organization",
      "@id": "https://shinobis.com/#organization",
      "name": "Shinobis",
      "url": "https://shinobis.com",
      "logo": { "@type": "ImageObject", "url": "https://shinobis.com/3117045.png" }
    },
    {
      "@type": "Person",
      "@id": "https://shinobis.com/#author",
      "name": "Shinobis",
      "description": "UX/UI Designer with 10+ years in banking and fintech.",
      "url": "https://shinobis.com/en/about",
      "jobTitle": "UX/UI Designer",
      "sameAs": [
        "https://www.linkedin.com/company/shinobis-ai",
        "https://dev.to/shinobis_ia"
      ]
    },
    {
      "@type": "WebPage",
      "@id": "https://shinobis.com/en/one-year-with-ai-open-letter-to-designers",
      "url": "https://shinobis.com/en/one-year-with-ai-open-letter-to-designers",
      "name": "One Year with AI: Open Letter to Designers",
      "isPartOf": { "@id": "https://shinobis.com/#website" }
    },
    {
      "@type": "BlogPosting",
      "@id": "https://shinobis.com/en/one-year-with-ai-open-letter-to-designers#article",
      "headline": "One Year with AI: Open Letter to Designers",
      "description": "One year ago I started this journey. Today I write to all designers who are still doubting, fearing, or ignoring AI.",
      "abstract": "One year ago I started this journey. Today I write to all designers who are still doubting, fearing, or ignoring AI.",
      "url": "https://shinobis.com/en/one-year-with-ai-open-letter-to-designers",
      "datePublished": "2026-02-15T09:00:00-05:00",
      "dateModified": "2026-03-20T14:30:00-05:00",
      "inLanguage": "en",
      "wordCount": 1842,
      "author": {
        "@type": "Person",
        "@id": "https://shinobis.com/#author",
        "name": "Shinobis",
        "url": "https://shinobis.com/en/about"
      },
      "publisher": {
        "@type": "Organization",
        "@id": "https://shinobis.com/#organization",
        "name": "Shinobis",
        "logo": { "@type": "ImageObject", "url": "https://shinobis.com/3117045.png" }
      },
      "isPartOf": { "@id": "https://shinobis.com/#website" },
      "mainEntityOfPage": {
        "@type": "WebPage",
        "@id": "https://shinobis.com/en/one-year-with-ai-open-letter-to-designers"
      },
      "about": [
        { "@type": "Thing", "name": "Midjourney", "url": "https://midjourney.com" },
        { "@type": "Thing", "name": "Prompt Engineering" }
      ],
      "mentions": [
        { "@type": "Thing", "name": "Claude", "url": "https://claude.ai" }
      ],
      "relatedLink": [
        "https://shinobis.com/en/ai-is-not-going-to-take-your-job-your-comfort-zone-will",
        "https://shinobis.com/en/the-designer-as-creative-director-of-machines"
      ],
      "citation": [
        "https://shinobis.com/en/ai-is-not-going-to-take-your-job-your-comfort-zone-will",
        "https://shinobis.com/en/the-designer-as-creative-director-of-machines"
      ],
      "keywords": ["Midjourney", "Prompt Engineering", "Claude"],
      "workTranslation": [
        {
          "@type": "CreativeWork",
          "@id": "https://shinobis.com/un-ano-con-ia-carta-abierta-disenadores#article",
          "headline": "Un año con IA: carta abierta a los diseñadores",
          "url": "https://shinobis.com/un-ano-con-ia-carta-abierta-disenadores",
          "inLanguage": "es"
        },
        {
          "@type": "CreativeWork",
          "@id": "https://shinobis.com/ja/one-year-with-ai-open-letter-to-designers#article",
          "headline": "AIと一年：デザイナーへの公開書簡",
          "url": "https://shinobis.com/ja/one-year-with-ai-open-letter-to-designers",
          "inLanguage": "ja"
        }
      ]
    }
  ]
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/69b380d893256dfc53256f05/943d4463-c7a7-4210-8e88-a3c72dd82d70.png" alt="Annotated JSON-LD output showing key properties: persistent @id, abstract for LLMs, auto-detected entities, citation relationships, and workTranslation for multilingual authority" style="display:block;margin:0 auto" width="437" height="816" loading="lazy">

<p>Compare that to the static version: one <code>BlogPosting</code> with a headline and an author name. The difference isn't cosmetic. It's the difference between "there is an article" and "there is a knowledge node connected to an author with verified profiles, published by an organization, linked to related articles through citation relationships, covering specific topics, and available in three languages."</p>
<h2 id="heading-testing-your-implementation">Testing Your Implementation</h2>
<p>After deploying, validate at <a href="https://search.google.com/test/rich-results">Google's Rich Results Test</a>. Paste any post URL and look for your BlogPosting with all properties.</p>
<p>For a deeper audit, copy the <code>&lt;script type="application/ld+json"&gt;</code> block from your page source and paste it into ChatGPT with this prompt: "Audit this JSON-LD schema for AI citation visibility. Score it 1-10 and tell me what is missing." The feedback is surprisingly specific.</p>
<p>When I did this, ChatGPT identified five improvements that raised the score from 8.7 to 9.1.</p>
<h2 id="heading-what-i-learned-after-3-months-in-production">What I Learned After 3 Months in Production</h2>
<p>I have been running this system on a blog with 52 posts in three languages since early 2026. Google indexed pages went from 26 to 48 in three months. The keyword "llms txt" reached position 4 on Google. AI models started citing my content in responses about JSON-LD implementation.</p>
<p>Three things I would do differently if starting today.</p>
<p>First, add the <code>abstract</code> property from day one. I added it three months in and the impact was immediate. LLMs use abstract as a first filter. Perplexity confirmed that the first 200 characters of a page are critical for whether AI extracts the content.</p>
<p>Second, use <code>citation</code> alongside <code>relatedLink</code> from the beginning. <code>relatedLink</code> is a navigation hint. <code>citation</code> signals a knowledge relationship. AI models interpret the connections between your posts differently depending on which property you use.</p>
<p>Third, define the publisher as an Organization immediately. I started with <code>@type: Person</code> and changed it later. AI systems assign more trust to organizational publishers.</p>
<p>The system generates JSON-LD on every page load. At this scale (under 100 posts) the performance impact is negligible. For thousands of posts, generate on publish and cache the output.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>This system is one layer of what is now called Generative Engine Optimization: structuring content so AI models cite you in their responses.</p>
<p>The other layers include an <a href="https://llmstxt.org">llms.txt</a> file at your domain root (which gives AI crawlers a site-level overview) and writing content that AI can extract without needing additional context (direct statements over narrative introductions).</p>
<p>The complete source code is running in production at <a href="https://shinobis.com">shinobis.com</a>. Every post uses the exact system described here.</p>
<p>The next SEO battlefield isn't rankings. It's citations. And citations start with structure.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Generate PDF Files in the Browser Using JavaScript (With a Real Invoice Example) ]]>
                </title>
                <description>
                    <![CDATA[ Generating PDF files is something most developers eventually need to do. Whether it’s invoices, reports, or downloadable documents, PDFs are still one of the most widely used formats. The usual approa ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-generate-pdf-files-in-the-browser-using-javascript/</link>
                <guid isPermaLink="false">69dfd90346ad31000bfc1474</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pdf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Bhavin Sheth ]]>
                </dc:creator>
                <pubDate>Wed, 15 Apr 2026 18:29:23 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/04433524-106f-4b86-b59d-3436a4a42761.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Generating PDF files is something most developers eventually need to do. Whether it’s invoices, reports, or downloadable documents, PDFs are still one of the most widely used formats.</p>
<p>The usual approach involves backend services. You send data to a server, generate the file there, and return it to the user. It works, but it adds complexity, latency, and maintenance overhead.</p>
<p>Modern browsers make this much simpler.</p>
<p>In this tutorial, you’ll learn how to generate PDF files directly in the browser using JavaScript. There’s no server involved, no file uploads, and everything happens instantly on the client side.</p>
<p>To make things practical, we’ll build a simple invoice-style PDF generator so you can see how this works in a real-world scenario.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-pdf-generation-works-in-the-browser">How PDF Generation Works in the Browser</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-what-library-are-we-using">What Library Are We Using?</a></p>
</li>
<li><p><a href="#heading-creating-the-html-structure">Creating the HTML Structure</a></p>
</li>
<li><p><a href="#heading-adding-javascript-to-generate-the-pdf">Adding JavaScript to Generate the PDF</a></p>
</li>
<li><p><a href="#heading-how-the-pdf-is-created">How the PDF Is Created</a></p>
</li>
<li><p><a href="#heading-handling-dynamic-content-important">Handling Dynamic Content (Important)</a></p>
</li>
<li><p><a href="#heading-improving-layout-and-spacing">Improving Layout and Spacing</a></p>
</li>
<li><p><a href="#heading-how-to-download-the-pdf">How to Download the PDF</a></p>
</li>
<li><p><a href="#heading-important-notes-from-real-world-use">Important Notes from Real-World Use</a></p>
</li>
<li><p><a href="#heading-common-mistakes-to-avoid">Common Mistakes to Avoid</a></p>
</li>
<li><p><a href="#heading-demo-how-the-pdf-generator-works">Demo: How the PDF Generator Works</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-how-pdf-generation-works-in-the-browser">How PDF Generation Works in the Browser</h2>
<p>A PDF is essentially a structured document that defines how text and elements are positioned on a page.</p>
<p>Instead of manually constructing that structure, we use a JavaScript library that handles it for us. You pass content into the library, and it generates a downloadable file.</p>
<p>The key advantage here is that everything runs locally. This makes the process faster and avoids sending any data to a server.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>This project is intentionally simple.</p>
<p>You only need an HTML file and a JavaScript file. There’s no backend, no API, and no database involved. This keeps the focus on understanding how PDF generation works inside the browser.</p>
<h2 id="heading-what-library-are-we-using">What Library Are We Using?</h2>
<p>We’ll use <strong>jsPDF</strong>, a lightweight library that allows you to create PDF files directly in JavaScript.</p>
<p>Add it using a CDN:</p>
<pre><code class="language-html">&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"&gt;&lt;/script&gt;
</code></pre>
<h2 id="heading-creating-the-html-structure">Creating the HTML Structure</h2>
<p>We’ll start with a simple interface where users can enter invoice data and generate a PDF.</p>
<pre><code class="language-html">&lt;input type="text" id="title" placeholder="Invoice Title"&gt;
&lt;textarea id="content" placeholder="Enter invoice details"&gt;&lt;/textarea&gt;
&lt;button onclick="generatePDF()"&gt;Generate PDF&lt;/button&gt;
</code></pre>
<p>This creates a basic input flow where users can provide the title and content for the PDF.</p>
<p>In real-world applications, this input could include more structured data like customer details, item lists, and pricing. But for this tutorial, we’ll keep things simple and focus on how the PDF generation works.</p>
<h2 id="heading-adding-javascript-to-generate-the-pdf">Adding JavaScript to Generate the PDF</h2>
<p>Now we connect the inputs to the PDF logic.</p>
<pre><code class="language-javascript">function generatePDF() {
  const { jsPDF } = window.jspdf;
  const doc = new jsPDF();

  const title = document.getElementById("title").value;
  const content = document.getElementById("content").value;

  if (!title.trim() &amp;&amp; !content.trim()) {
    alert("Please enter valid content before generating the PDF.");
    return;
  }

  const margin = 10;
  let y = 20;

  const pageWidth = doc.internal.pageSize.getWidth();
  const pageHeight = doc.internal.pageSize.getHeight();
  const maxWidth = pageWidth - margin * 2;

  doc.setFontSize(18);

  // ✅ Wrap title
  const titleLines = doc.splitTextToSize(title, maxWidth);
  doc.text(titleLines, margin, y);

  const titleLineHeight = doc.getLineHeight() / doc.internal.scaleFactor;
  y += titleLines.length * titleLineHeight + 5;

  doc.setFontSize(12);

  // ✅ Wrap content
  const lines = doc.splitTextToSize(content, maxWidth);

  const lineHeight = doc.getLineHeight() / doc.internal.scaleFactor;

  lines.forEach((line) =&gt; {
    // ✅ Page break
    if (y &gt; pageHeight - margin) {
      doc.addPage();
      y = margin;
    }

    doc.text(line, margin, y);
    y += lineHeight;
  });

  doc.save("invoice.pdf");
}
</code></pre>
<p>This creates a PDF directly in the browser. It handles long text, maintains proper spacing, and automatically adds new pages if the content exceeds the page height.</p>
<h2 id="heading-how-the-pdf-is-created">How the PDF Is Created</h2>
<p>When you initialize jsPDF, it creates a blank document.</p>
<p>Each <code>text()</code> call places content at a specific coordinate. This gives you full control over layout, but it also means you need to manage spacing carefully.</p>
<p>Finally, calling <code>save()</code> converts everything into a downloadable file.</p>
<h2 id="heading-handling-dynamic-content-important">Handling Dynamic Content (Important)</h2>
<p>In real-world use cases like invoices, content length is rarely fixed. If a user enters multiple lines or longer text, it can overflow or go outside the page.</p>
<p>To handle this, you should wrap text based on the page width instead of using fixed values.</p>
<pre><code class="language-javascript">const pageWidth = doc.internal.pageSize.getWidth();
const margin = 10;
const maxWidth = pageWidth - margin * 2;

const lines = doc.splitTextToSize(content, maxWidth);
doc.text(lines, margin, 40);
</code></pre>
<p>This ensures your content wraps properly and fits within the page.</p>
<p>If the content is long, you should also update spacing dynamically:</p>
<pre><code class="language-javascript">const lineHeight = doc.getLineHeight() / doc.internal.scaleFactor;
let y = 40;

lines.forEach((line) =&gt; {
  doc.text(line, margin, y);
  y += lineHeight;
});
</code></pre>
<p>This keeps the layout readable and prevents overlapping when working with dynamic input.</p>
<h2 id="heading-improving-layout-and-spacing">Improving Layout and Spacing</h2>
<p>Good layout makes a big difference in how your PDF looks and feels.</p>
<p>Instead of placing everything at fixed positions, you can gradually adjust the Y position as content grows. This helps prevent overlapping and keeps the document visually structured.</p>
<p>For example, instead of hardcoding positions, you can do something like this:</p>
<pre><code class="language-javascript">const margin = 10;
let y = 20;

const pageWidth = doc.internal.pageSize.getWidth();
const maxWidth = pageWidth - margin * 2;

doc.setFontSize(18);

// Wrap title
const titleLines = doc.splitTextToSize(title, maxWidth);
doc.text(titleLines, margin, y);

const lineHeight = doc.getLineHeight() / doc.internal.scaleFactor;
y += titleLines.length * lineHeight + 5;

doc.setFontSize(12);

// Wrap content
const lines = doc.splitTextToSize(content, maxWidth);
doc.text(lines, margin, y);

y += lines.length * lineHeight;
</code></pre>
<p>Here, the <code>y</code> value increases based on actual content height instead of fixed spacing. This ensures consistent spacing between elements and avoids overlapping.</p>
<p>Another important issue is handling long text. If content is too long, it can go outside the page width or overlap with other elements. Instead of using fixed values, you should always calculate width dynamically:</p>
<pre><code class="language-javascript">const pageWidth = doc.internal.pageSize.getWidth();
const maxWidth = pageWidth - margin * 2;

const lines = doc.splitTextToSize(content, maxWidth);
doc.text(lines, margin, y);
</code></pre>
<p>This automatically breaks the text into multiple lines so it fits properly within the page.</p>
<p>Using dynamic spacing and text wrapping together ensures that your layout remains clean and readable, even when the content size changes. This becomes especially important when generating documents like invoices, where multiple sections need consistent alignment.</p>
<h2 id="heading-how-to-download-the-pdf">How to Download the PDF</h2>
<p>The download process is handled using the <code>save()</code> method:</p>
<pre><code class="language-javascript">doc.save("invoice.pdf");
</code></pre>
<p>This tells the browser to generate the PDF and download it instantly.</p>
<p>You can also customize the file name dynamically based on user input:</p>
<pre><code class="language-javascript">const fileName = (title || "document").trim() + ".pdf";
doc.save(fileName);
</code></pre>
<p>This makes the downloaded file more meaningful instead of always using a fixed name.</p>
<p>Since everything runs in the browser, no server is involved and no data is uploaded. This makes the process fast and keeps user data private.</p>
<h2 id="heading-important-notes-from-real-world-use">Important Notes from Real-World Use</h2>
<p>When building tools like invoice generators, layout control becomes more important than the logic itself.</p>
<p>In a browser, layouts are flexible. But in a PDF, everything is fixed. That means you need to carefully control spacing, positioning, and readability.</p>
<p>For example, if you add multiple sections without adjusting spacing, content can easily overlap. Instead of using fixed positions, it’s better to update the Y position dynamically as content grows:</p>
<pre><code class="language-javascript">let y = 20;

doc.text("Invoice Title", 10, y);
y += 10;

doc.text("Customer Name", 10, y);
y += 10;
</code></pre>
<p>This ensures each section appears below the previous one without overlapping.</p>
<p>Another common issue is long content. If text is too long, it won’t automatically wrap like it does in HTML. You need to handle this manually using dynamic width:</p>
<pre><code class="language-javascript">const pageWidth = doc.internal.pageSize.getWidth();
const margin = 10;
const maxWidth = pageWidth - margin * 2;

const lines = doc.splitTextToSize(content, maxWidth);
doc.text(lines, margin, y);

const lineHeight = doc.getLineHeight() / doc.internal.scaleFactor;
y += lines.length * lineHeight;
</code></pre>
<p>This keeps the text readable and ensures it fits within the page.</p>
<p>You also need to think about how screen inputs translate into a fixed-size document. For example, a long description in a textarea may look fine on screen, but in a PDF it needs proper spacing, wrapping, and sometimes even pagination.</p>
<h3 id="heading-optimizing-pdf-generation-performance">Optimizing PDF Generation Performance</h3>
<p>Performance is another important factor. Generating large PDFs with a lot of content can slow down rendering in the browser.</p>
<p>One simple approach is to limit input size:</p>
<pre><code class="language-javascript">if (content.length &gt; 2000) {
  alert("Content is too large. Consider splitting it into multiple sections.");
  return;
}
</code></pre>
<p>Another approach is to split content across multiple pages instead of forcing everything onto one page:</p>
<pre><code class="language-javascript">const pageHeight = doc.internal.pageSize.getHeight();
const lineHeight = doc.getLineHeight() / doc.internal.scaleFactor;

lines.forEach((line) =&gt; {
  if (y &gt; pageHeight - margin) {
    doc.addPage();
    y = margin;
  }

  doc.text(line, margin, y);
  y += lineHeight;
});
</code></pre>
<p>This ensures large content is handled efficiently without breaking layout or performance.</p>
<p>In real-world tools, small decisions like spacing, wrapping, pagination, and content limits make a big difference in how usable and professional your generated PDFs feel.</p>
<h2 id="heading-common-mistakes-to-avoid">Common Mistakes to Avoid</h2>
<p>One common issue is skipping validation. If users generate a PDF with empty fields, the result won’t be useful.</p>
<p>To avoid this, always validate input properly and handle whitespace:</p>
<pre><code class="language-javascript">if (!title.trim() &amp;&amp; !content.trim()) {
  alert("Please enter valid content before generating the PDF.");
  return;
}
</code></pre>
<p>This ensures users don’t download empty or broken PDFs.</p>
<p>Another mistake is ignoring text overflow. In a browser, text wraps automatically, but in a PDF it does not. Without handling this, long content can overlap or go outside the page.</p>
<p>You can fix this using dynamic text wrapping:</p>
<pre><code class="language-javascript">const pageWidth = doc.internal.pageSize.getWidth();
const margin = 10;
const maxWidth = pageWidth - margin * 2;

const lines = doc.splitTextToSize(content, maxWidth);
doc.text(lines, margin, 40);
</code></pre>
<p>This keeps the content inside the page and improves readability.</p>
<p>A related issue is overlapping content caused by fixed positioning. If you place everything at static coordinates, sections can stack on top of each other.</p>
<p>Instead, update positions dynamically:</p>
<pre><code class="language-javascript">let y = 20;

doc.text(title, 10, y);
y += 10;

const lines = doc.splitTextToSize(content, maxWidth);
doc.text(lines, 10, y);

const lineHeight = doc.getLineHeight() / doc.internal.scaleFactor;
y += lines.length * lineHeight;
</code></pre>
<p>This keeps spacing consistent and prevents layout issues.</p>
<p>Finally, forgetting to load the jsPDF library properly will break the entire feature. If the script is missing or incorrect, the PDF won’t generate at all.</p>
<p>Always make sure the CDN is included correctly:</p>
<pre><code class="language-html">&lt;script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"&gt;&lt;/script&gt;
</code></pre>
<p>In practice, most issues come down to proper validation, dynamic spacing, and handling content size correctly. Fixing these early makes your PDF generator much more reliable.</p>
<h2 id="heading-demo-how-the-pdf-generator-works">Demo: How the PDF Generator Works</h2>
<p>For this example, we’ll generate a simple invoice PDF to demonstrate how this works in a real-world scenario.</p>
<h3 id="heading-step-1-enter-company-details">Step 1: Enter Company Details</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/d2c80d01-5632-41b6-a178-0236d5b78ab6.png" alt="Invoice generator form showing company information fields like company name, address, email, phone, and GST details" style="display:block;margin:0 auto" width="538" height="818" loading="lazy">

<p>Start by entering your company details such as name, address, contact information, and other identifiers. This data will appear at the top of the generated invoice.</p>
<h3 id="heading-step-2-add-customer-information">Step 2: Add Customer Information</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/e5b1ecbc-2a93-41cb-a2e2-fd8841ce972a.png" alt="Customer information section with fields for customer name, billing address, shipping address, and contact details" style="display:block;margin:0 auto" width="477" height="640" loading="lazy">

<p>Next, fill in the customer details including billing and shipping addresses. This ensures the invoice is correctly assigned.</p>
<h3 id="heading-step-3-enter-invoice-details">Step 3: Enter Invoice Details</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/e3adf212-d1e4-44bc-a629-2ab6cdd68ba4.png" alt="Invoice details form showing invoice number, invoice date, due date, and additional notes fields" style="display:block;margin:0 auto" width="480" height="633" loading="lazy">

<p>Provide invoice-specific details such as invoice number, dates, and any additional notes. These values help structure the document properly.</p>
<h3 id="heading-step-4-add-items-to-the-invoice">Step 4: Add Items to the Invoice</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/1acb5bad-70c3-40bb-95ed-6abb5eec89be.png" alt="Invoice items section with multiple items, quantity, rate, tax, discount, and total calculation fields" style="display:block;margin:0 auto" width="459" height="844" loading="lazy">

<p>Add the items or services included in the invoice. Each item can include quantity, pricing, tax, and discounts, which are automatically calculated.</p>
<h3 id="heading-step-5-configure-payment-and-terms">Step 5: Configure Payment and Terms</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/ca4a893d-9131-4e8d-9dbf-aeaa70a68285.png" alt="Payment and terms section showing payment instructions, QR code option, terms and conditions, and signature fields" style="display:block;margin:0 auto" width="479" height="691" loading="lazy">

<p>Define payment instructions, terms, and any additional conditions. This section ensures the invoice is complete and ready for real use.</p>
<h3 id="heading-step-6-preview-the-generated-invoice">Step 6: Preview the Generated Invoice</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/aa31817f-9e1d-4c74-b719-9e4149da821e.png" alt="Live invoice preview displaying company details, customer info, item table, totals, and final invoice layout" style="display:block;margin:0 auto" width="325" height="862" loading="lazy">

<p>The interface provides a live preview of the invoice so you can review everything before generating the PDF.</p>
<h3 id="heading-step-7-generate-and-download-the-pdf">Step 7: Generate and Download the PDF</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/05e56924-f6c1-4033-adf4-3b8ce0070e8d.png" alt="Quick stats and action buttons showing total amount, total tax, and generate PDF button" style="display:block;margin:0 auto" width="329" height="604" loading="lazy">

<p>Finally, click the generate button to create and download the PDF instantly. The file is generated directly in the browser without any server interaction.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a PDF generator using JavaScript that runs entirely in the browser.</p>
<p>More importantly, you learned how to think about building real tools using client-side capabilities. This approach reduces complexity, improves performance, and keeps user data private.</p>
<p>Once you understand this pattern, you can extend it to build more advanced tools like invoice systems, report generators, and document exporters.</p>
<p>And that’s where things start to get really interesting.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Online Marketplace with Next.js, Express, and Stripe Connect ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever wondered how platforms like Etsy, Uber, or Teachable handle payments for thousands of sellers? The answer is a multi-vendor marketplace: an application where merchants can sign up, list  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-online-marketplace-with-next-js-express-stripe-connect/</link>
                <guid isPermaLink="false">69d7ca9dfa7251682ec4b098</guid>
                
                    <category>
                        <![CDATA[ stripe ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Michael Okolo ]]>
                </dc:creator>
                <pubDate>Thu, 09 Apr 2026 15:49:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1181805a-87ae-440d-9673-64efeb073aad.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever wondered how platforms like Etsy, Uber, or Teachable handle payments for thousands of sellers? The answer is a <strong>multi-vendor marketplace</strong>: an application where merchants can sign up, list products or services, and receive payments directly from customers.</p>
<p>In this handbook, you'll build a complete marketplace from scratch using TypeScript. You won't need a traditional database. Instead, you'll use Stripe as your product catalog and payment engine.</p>
<p>This is how many real-world marketplaces work: Stripe stores the products, prices, and customer data, while your application handles the user experience.</p>
<p>Here's what you'll build:</p>
<ol>
<li><p>A merchant onboarding flow where sellers create accounts and connect with Stripe</p>
</li>
<li><p>A product management system where merchants can add and list products directly through Stripe</p>
</li>
<li><p>A checkout flow that supports both one-time payments and recurring subscriptions</p>
</li>
<li><p>Webhooks that listen for payment events in real time</p>
</li>
<li><p>A billing portal where customers can manage their subscriptions</p>
</li>
<li><p>A complete storefront where customers can browse and buy products</p>
</li>
</ol>
<p>You can also grab the complete source code from the GitHub repository linked at the end.</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-is-stripe-connect">What is Stripe Connect?</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-set-up-the-backend">How to Set Up the Backend</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-express-backend">How to Build the Express Backend</a></p>
</li>
<li><p><a href="#heading-how-to-handle-merchant-onboarding">How to Handle Merchant Onboarding</a></p>
<ul>
<li><p><a href="#heading-how-to-create-a-connected-account">How to Create a Connected Account</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-onboarding-link">How to Create the Onboarding Link</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-check-account-status">How to Check Account Status</a></p>
</li>
<li><p><a href="#heading-how-to-create-products-through-stripe">How to Create Products Through Stripe</a></p>
</li>
<li><p><a href="#heading-how-to-fetch-products">How to Fetch Products</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-checkout-flow">How to Build the Checkout Flow</a></p>
</li>
<li><p><a href="#heading-how-to-handle-webhooks">How to Handle Webhooks</a></p>
</li>
<li><p><a href="#heading-how-to-configure-webhooks-in-the-stripe-dashboard">How to Configure Webhooks in the Stripe Dashboard</a></p>
</li>
<li><p><a href="#heading-how-to-test-webhooks-locally">How to Test Webhooks Locally</a></p>
</li>
<li><p><a href="#heading-how-to-add-the-billing-portal">How to Add the Billing Portal</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-nextjs-frontend">How to Build the Next.js Frontend</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-account-context">How to Create the Account Context</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-account-status-hook">How to Create the Account Status Hook</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-merchant-onboarding-component">How to Build the Merchant Onboarding Component</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-product-create-product-list-and-checkout">How to Build the Product Create, Product List and Checkout</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-product-form">How to Build the Product Form</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-main-page">How to Build the Main Page</a></p>
</li>
<li><p><a href="#heading-how-to-test-the-full-flow">How to Test the Full Flow</a></p>
</li>
<li><p><a href="#heading-how-the-payment-split-works">How the Payment Split Works</a></p>
</li>
<li><p><a href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a href="#heading-acknowledgements">Acknowledgements</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you begin, make sure you have the following:</p>
<ol>
<li><p>Node.js (version 18 or higher) installed on your machine</p>
</li>
<li><p>A basic understanding of React, TypeScript, and REST APIs</p>
</li>
<li><p>A Stripe account (sign up for free at <a href="http://stripe.com">stripe.com</a>)</p>
</li>
<li><p>A code editor like VS Code</p>
</li>
</ol>
<p>You do <strong>not</strong> need a database for this project. Stripe will store your products, prices, and customer information. This keeps the architecture simple and mirrors how many production marketplaces actually work.</p>
<h2 id="heading-what-is-stripe-connect"><strong>What is Stripe Connect?</strong></h2>
<p>Stripe Connect is a set of APIs designed for platforms and marketplaces. It lets you create accounts for your merchants (Stripe calls them "connected accounts"), route payments to them, and take a platform fee on every transaction.</p>
<p>In this tutorial, you will use Stripe’s <strong>V2 Accounts API</strong>, which is the newer and recommended way to create connected accounts. With the V2 API, you configure what each account can do (accept card payments, receive payouts) through a configuration object, and Stripe handles all compliance and identity verification through a hosted onboarding flow.</p>
<p>Here's how the payment flow works:</p>
<ol>
<li><p>A customer selects a product and clicks checkout on your marketplace.</p>
</li>
<li><p>Your server creates a Stripe Checkout Session linked to the merchant’s connected account.</p>
</li>
<li><p>The customer pays on Stripe’s hosted checkout page.</p>
</li>
<li><p>Stripe automatically splits the payment: the merchant gets their share, and your platform keeps an application fee.</p>
</li>
<li><p>Stripe sends a webhook event to your server confirming the payment.</p>
</li>
<li><p>The merchant can view their earnings and withdraw funds from their Stripe dashboard.</p>
</li>
</ol>
<h2 id="heading-how-to-set-up-the-project"><strong>How to Set Up the Project</strong></h2>
<p>Create a project folder with separate directories for your backend and frontend:</p>
<pre><code class="language-shell">mkdir marketplace &amp;&amp; cd marketplace
mkdir server client
</code></pre>
<h2 id="heading-how-to-set-up-the-backend"><strong>How to Set Up the Backend</strong></h2>
<p>Navigate into the server directory and initialize a TypeScript project:</p>
<pre><code class="language-shell">cd server
npm init -y
npm install express cors dotenv stripe
npm install -D typescript ts-node @types/express @types/cors @types/node
npx tsc --init
mkdir src
</code></pre>
<p>Open tsconfig.json and update it with these settings:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}
</code></pre>
<p>Then create a .env file in the server root:</p>
<pre><code class="language-plaintext">STRIPE_SECRET_KEY=sk_test_your_key_here
DOMAIN=http://localhost:3000
</code></pre>
<p>You can find your Stripe test secret key in the Stripe Dashboard under Developers &gt; API Keys. The DOMAIN variable tells your server where to redirect customers after checkout.</p>
<p>Add these scripts to your package.json:</p>
<pre><code class="language-json">{
&nbsp; "scripts": {
    "dev": "ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}
</code></pre>
<h2 id="heading-how-to-build-the-express-backend"><strong>How to Build the Express Backend</strong></h2>
<p>Create the file src/index.ts. This will be your entire backend. Let’s start with the setup and imports:</p>
<pre><code class="language-typescript">import express, { Request, Response, Router } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import Stripe from 'stripe';

dotenv.config();

const app = express();
const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);

app.use(cors({ origin: process.env.DOMAIN }));
app.use(express.static('public'));
</code></pre>
<p>Notice that we don't import any database client. Stripe is our data layer. Every product, price, customer, and transaction lives in Stripe. Your Express server is a thin orchestration layer that talks to the Stripe API on behalf of your frontend.</p>
<p>We also mount <code>express.static("public")</code> so you can serve static files later if needed. The webhook endpoint needs the raw request body, so we'll register it before the JSON parser. Let’s add that now.</p>
<h2 id="heading-how-to-handle-merchant-onboarding"><strong>How to Handle Merchant Onboarding</strong></h2>
<p>The first thing a merchant needs to do is create an account on your platform and connect it to Stripe. This involves two steps: creating a connected account, and then redirecting the merchant to Stripe’s hosted onboarding form.</p>
<h3 id="heading-how-to-create-a-connected-account">How to Create a Connected Account</h3>
<p>Add the following route to your src/index.ts:</p>
<pre><code class="language-typescript">// Type definitions for request bodies
interface CreateAccountBody {
  email: string;
}
interface AccountIdBody {
  accountId: string;
}

// Create a Connected Account using Stripe V2 API
router.post(
  '/create-connect-account',
  async (req: Request&lt;{}, {}, CreateAccountBody&gt;, res: Response) =&gt; {
    try {
      const account = await stripe.v2.core.accounts.create({
        display_name: req.body.email,
        contact_email: req.body.email,
        dashboard: 'full',
        defaults: {
          responsibilities: {
            fees_collector: 'stripe',
            losses_collector: 'stripe',
          },
        },
        identity: {
          country: 'GB',
          entity_type: 'company',
        },
        configuration: {
          customer: {},
          merchant: {
            capabilities: {
              card_payments: { requested: true },
            },
          },
        },
      });
      res.json({ accountId: account.id });
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      res.status(500).json({ error: message });
    }
  },
);
</code></pre>
<p>Let’s break down what this code does. The <code>stripe.v2.core.accounts.create()</code> method creates a new connected account using Stripe’s V2 API. Here are the key configuration options:</p>
<ol>
<li><p><code>dashboard: "full"</code> gives the merchant access to their own Stripe dashboard where they can view payments, manage payouts, and handle disputes.</p>
</li>
<li><p><code>responsibilities</code> tells Stripe who collects fees and who is liable for losses. Setting both to "stripe" means Stripe handles this, which is the simplest configuration.</p>
</li>
<li><p><code>identity</code> sets the country and entity type. Change "GB" to your merchants’ country code (for example, "US" for the United States).</p>
</li>
<li><p><code>configuration.merchant.capabilities</code> requests the <code>card_payments</code> capability, which lets the merchant accept credit card payments.</p>
</li>
</ol>
<h3 id="heading-how-to-create-the-onboarding-link">How to Create the Onboarding Link</h3>
<p>After creating the account, you need to redirect the merchant to Stripe’s hosted onboarding form. Add this route:</p>
<pre><code class="language-typescript">// Create Account Link for onboarding
router.post('/create-account-link', async (req: Request&lt;{}, {}, AccountIdBody&gt;, res: Response) =&gt; {
  const { accountId } = req.body;
  try {
    const accountLink = await stripe.v2.core.accountLinks.create({
      account: accountId,
      use_case: {
        type: 'account_onboarding',
        account_onboarding: {
          configurations: ['merchant', 'customer'],
          refresh_url: `${process.env.DOMAIN}`,
          return_url: `\({process.env.DOMAIN}?accountId=\){accountId}`,
        },
      },
    });
    res.json({ url: accountLink.url });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(500).json({ error: message });
  }
});
</code></pre>
<p>The <code>accountLinks.create()</code> method generates a temporary URL that takes the merchant to Stripe’s onboarding form. On that form, Stripe collects the merchant’s identity documents, bank account details, and tax information. You don't need to build any of this yourself.</p>
<p>The <code>return_url</code> is where Stripe redirects the merchant after they complete onboarding. Notice that you append the <code>accountId</code> as a query parameter so your frontend can pick it up and store it.</p>
<h2 id="heading-how-to-check-account-status"><strong>How to Check Account Status</strong></h2>
<p>You need a way to check whether a merchant has finished onboarding and is ready to accept payments. Add this route:</p>
<pre><code class="language-typescript">// Get Connected Account Status
router.get(
  '/account-status/:accountId',
  async (req: Request&lt;{ accountId: string }&gt;, res: Response) =&gt; {
    try {
      const account = await stripe.v2.core.accounts.retrieve(req.params.accountId, {
        include: ['requirements', 'configuration.merchant'],
      });
      const payoutsEnabled =
        account.configuration?.merchant?.capabilities?.stripe_balance?.payouts?.status === 'active';
      const chargesEnabled =
        account.configuration?.merchant?.capabilities?.card_payments?.status === 'active';
      const summaryStatus = account.requirements?.summary?.minimum_deadline?.status;
      const detailsSubmitted = !summaryStatus || summaryStatus === 'eventually_due';
      res.json({
        id: account.id,
        payoutsEnabled,
        chargesEnabled,
        detailsSubmitted,
        requirements: account.requirements?.entries,
      });
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      res.status(500).json({ error: message });
    }
  },
);
</code></pre>
<p>This route retrieves the connected account and checks three important statuses:</p>
<ul>
<li><p><code>chargesEnabled</code> tells you if the merchant can accept payments.</p>
</li>
<li><p><code>payoutsEnabled</code> tells you if they can receive payouts to their bank account.</p>
</li>
<li><p><code>detailsSubmitted</code> tells you if they have completed the onboarding form.</p>
</li>
</ul>
<p>Your frontend will use these flags to show or hide features.</p>
<h2 id="heading-how-to-create-products-through-stripe"><strong>How to Create Products Through Stripe</strong></h2>
<p>Instead of storing products in a database, you'll create them directly in Stripe. Each product is created on the merchant’s connected account using the <code>stripeAccount</code> header. This means each merchant has their own isolated product catalog inside Stripe.</p>
<pre><code class="language-typescript">// Type definition for product creation
interface CreateProductBody {
  productName: string;
  productDescription: string;
  productPrice: number;
  accountId: string;
}
// Create a product on the connected account
router.post('/create-product', async (req: Request&lt;{}, {}, CreateProductBody&gt;, res: Response) =&gt; {
  const { productName, productDescription, productPrice, accountId } = req.body;
  try {
    // Create the product on the connected account
    const product = await stripe.products.create(
      {
        name: productName,
        description: productDescription,
      },
      { stripeAccount: accountId },
    ); // Create a price for the product
    const price = await stripe.prices.create(
      {
        product: product.id,
        unit_amount: productPrice,
        currency: 'usd',
      },
      { stripeAccount: accountId },
    );
    res.json({
      productName,
      productDescription,
      productPrice,
      priceId: price.id,
    });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(500).json({ error: message });
  }
});
</code></pre>
<p>There are two Stripe API calls happening here. First, <code>stripe.products.create()</code> creates the product (name and description). Then <code>stripe.prices.create()</code> creates a price for that product (amount and currency).</p>
<p>Stripe separates products from prices because a single product can have multiple prices — for example, a monthly plan and an annual plan.</p>
<p>The <code>{ stripeAccount: accountId }</code> option on both calls tells Stripe to create these resources on the merchant’s connected account, not on your platform account. This is a critical detail: without it, the products would be created on your platform’s account and the merchant would never see them.</p>
<h2 id="heading-how-to-fetch-products"><strong>How to Fetch Products</strong></h2>
<p>Add a route to list all products for a given merchant:</p>
<pre><code class="language-typescript">// Fetch products for a specific account
router.get('/products/:accountId', async (req: Request&lt;{ accountId: string }&gt;, res: Response) =&gt; {
  const { accountId } = req.params;
  try {
    const options: Stripe.RequestOptions = {};
    if (accountId !== 'platform') {
      options.stripeAccount = accountId;
    }
    const prices = await stripe.prices.list(
      {
        expand: ['data.product'],
        active: true,
        limit: 100,
      },
      options,
    );
    const products = prices.data.map((price) =&gt; {
      const product = price.product as Stripe.Product;
      return {
        id: product.id,
        name: product.name,
        description: product.description,
        price: price.unit_amount,
        priceId: price.id,
        period: price.recurring ? price.recurring.interval : null,
      };
    });
    res.json(products);
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(500).json({ error: message });
  }
});
</code></pre>
<p>This route fetches all active prices from a merchant’s Stripe account and expands the product data (using <code>expand: ["data.product"]</code>) so you get the product name and description in the same API call. The period field will be null for one-time products and "month" or "year" for subscriptions.</p>
<h2 id="heading-how-to-build-the-checkout-flow"><strong>How to Build the Checkout Flow</strong></h2>
<p>Your checkout flow needs to handle two scenarios: one-time payments for individual products, and recurring subscriptions. Stripe’s Checkout Sessions handle both — you just need to set the mode based on the price type.</p>
<pre><code class="language-typescript">// Type definition for checkout
interface CheckoutBody {
  priceId: string;
  accountId: string;
}
// Create checkout session
router.post(
  '/create-checkout-session',
  async (req: Request&lt;{}, {}, CheckoutBody&gt;, res: Response) =&gt; {
    const { priceId, accountId } = req.body;
    try {
      // Retrieve the price to determine if it is
      // one-time or recurring
      const price = await stripe.prices.retrieve(priceId, { stripeAccount: accountId });
      const isSubscription = price.type === 'recurring';
      const mode = isSubscription ? 'subscription' : 'payment';
      const session = await stripe.checkout.sessions.create(
        {
          line_items: [
            {
              price: priceId,
              quantity: 1,
            },
          ],
          mode,
          success_url: `${process.env.DOMAIN}/done?session_id={CHECKOUT_SESSION_ID}`,
          cancel_url: `${process.env.DOMAIN}`,
          ...(isSubscription
            ? {
                subscription_data: {
                  application_fee_percent: 10,
                },
              }
            : {
                payment_intent_data: {
                  application_fee_amount: 123,
                },
              }),
        },
        { stripeAccount: accountId },
      );
      res.redirect(303, session.url as string);
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      res.status(500).json({ error: message });
    }
  },
);
</code></pre>
<p>Here's what this route does step by step. First, it retrieves the price from the merchant’s connected account to check whether it is a one-time price or a recurring subscription. Then it creates a Checkout Session with the appropriate mode — either "payment" or "subscription".</p>
<p>The <code>application_fee_amount</code> is your platform’s cut of the transaction, specified in the smallest currency unit (cents for USD). In this example, you take $1.23 or 10% per transaction. For a real marketplace, you would likely calculate this as a percentage of the product price.</p>
<p>Notice that <code>application_fee_amount</code> goes inside <code>subscription_data</code> for subscriptions but inside <code>payment_intent_data</code> for one-time payments. This is a Stripe requirement — the two modes use different configuration objects.</p>
<p>Finally, the route uses <code>res.redirect(303, session.url)</code> to send the customer directly to Stripe’s hosted checkout page.</p>
<h2 id="heading-how-to-handle-webhooks"><strong>How to Handle Webhooks</strong></h2>
<p>Webhooks are how Stripe tells your server about events that happen asynchronously — like a successful payment, a failed charge, or a subscription cancellation.</p>
<p>In a production marketplace, you should <strong>never</strong> rely solely on redirect URLs to confirm payments. A customer might close their browser before the redirect completes. Webhooks are your source of truth.</p>
<p>Add the webhook endpoint <strong>before</strong> the JSON body parser. Stripe sends webhook payloads as raw bytes, and you need the raw body to verify the signature:</p>
<pre><code class="language-typescript">// IMPORTANT: Register this BEFORE app.use(express.json())
app.post(
  '/api/webhook',
  express.raw({ type: 'application/json' }),
  (req: Request, res: Response) =&gt; {
    let event: Stripe.Event = JSON.parse(req.body.toString()); // If you have an endpoint secret, verify the
    // signature for security
    const endpointSecret = process.env.WEBHOOK_SECRET;
    if (endpointSecret) {
      const signature = req.headers['stripe-signature'] as string;
      try {
        event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret) as Stripe.Event;
      } catch (err) {
        const message = err instanceof Error ? err.message : 'Unknown error';
        console.log('Webhook signature verification failed:', message);
        res.sendStatus(400);
        return;
      }
    } // Handle the event
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Payment successful for session:', session.id); // Fulfill the order: send email, grant access,
        // update your records, and so on
        break;
      }
      case 'checkout.session.expired': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Session expired:', session.id); // Optionally notify the customer or clean up
        // any pending records
        break;
      }
      case 'checkout.session.async_payment_succeeded': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Delayed payment succeeded for session:', session.id); // Fulfill the order now that payment cleared
        break;
      }
      case 'checkout.session.async_payment_failed': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Payment failed for session:', session.id); // Notify the customer that payment failed
        break;
      }
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        console.log('Subscription cancelled:', subscription.id); // Revoke access for the customer
        break;
      }
      default:
        console.log('Unhandled event type:', event.type);
    }
    res.send();
  },
);
</code></pre>
<p>The webhook handler checks for five key events.</p>
<ul>
<li><p><code>checkout.session.completed</code> fires when a payment succeeds — this is where you would fulfill an order, send a confirmation email, or grant access.</p>
</li>
<li><p><code>checkout.session.expired</code> fires when a session expires before the customer completes payment.</p>
</li>
<li><p><code>checkout.session.async_payment_succeeded</code> fires when a delayed payment method (like a bank transfer) finally goes through.</p>
</li>
<li><p><code>checkout.session.async_payment_failed</code> fires when a delayed payment method fails.</p>
</li>
<li><p>And <code>customer.subscription.deleted</code> fires when a subscription is cancelled.</p>
</li>
</ul>
<h2 id="heading-how-to-configure-webhooks-in-the-stripe-dashboard"><strong>How to Configure Webhooks in the Stripe Dashboard</strong></h2>
<p>Before you can receive webhook events, you need to tell Stripe where to send them and which events you care about. Follow these steps:</p>
<ol>
<li><p>Go to the Stripe Dashboard and navigate to Developers &gt; Webhooks.</p>
</li>
<li><p>Click "Add destination."</p>
</li>
<li><p>Under the account type, select "Connected and V2 accounts" since your payments go through connected merchant accounts.</p>
</li>
<li><p>Under "Events to listen for," click "All events" and select the following five events:</p>
<ul>
<li><p><code>checkout.session.async_payment_succeeded</code> — Occurs when a payment intent using a delayed payment method finally succeeds.</p>
</li>
<li><p><code>checkout.session.completed</code> — Occurs when a Checkout Session has been successfully completed.</p>
</li>
<li><p><code>checkout.session.expired</code> — Occurs when a Checkout Session expires before completion.</p>
</li>
<li><p><code>checkout.session.async_payment_failed</code> — Occurs when a payment intent using a delayed payment method fails.</p>
</li>
<li><p><code>customer.subscription.deleted</code> — Occurs whenever a customer’s subscription ends.</p>
</li>
</ul>
</li>
<li><p>Enter your webhook endpoint URL. For production, this would be something like <a href="https://yourdomain.com/api/webhook">https://yourdomain.com/api/webhook</a>. For local development, you will use the Stripe CLI instead (covered next).</p>
</li>
<li><p>Click "Add destination" to save.</p>
</li>
</ol>
<h2 id="heading-how-to-test-webhooks-locally"><strong>How to Test Webhooks Locally</strong></h2>
<p>For local development, you don't need to expose your server to the internet. Install the Stripe CLI and run:</p>
<pre><code class="language-shell">brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:4242/webhook
</code></pre>
<p>The CLI will print a webhook signing secret that starts with <code>whsec_</code>. Add this to your .env file as <code>WEBHOOK_SECRET</code>. The CLI intercepts all webhook events from Stripe and forwards them to your local server, so you can test the full payment flow without deploying anything.</p>
<h2 id="heading-how-to-add-the-billing-portal"><strong>How to Add the Billing Portal</strong></h2>
<p>The billing portal lets customers manage their subscriptions without you building any UI for it. Stripe hosts the entire experience — customers can update their payment method, change plans, or cancel their subscription.</p>
<pre><code class="language-typescript">// Create a billing portal session
router.post(
&nbsp; "/create-portal-session",
&nbsp; async (req: Request, res: Response) =&gt; {
&nbsp;&nbsp;&nbsp; const { session_id } = req.body as {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; session_id: string;
&nbsp;&nbsp;&nbsp; };
&nbsp;
&nbsp;&nbsp;&nbsp; try {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const session =
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; await stripe.checkout.sessions.retrieve(
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; session_id
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; );
&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const portalSession =
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; await stripe.billingPortal.sessions.create({
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; customer_account: session.customer_account as string,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; return_url: `\({process.env.DOMAIN}?session_id=\){session_id}`,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; });
&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; res.redirect(303, portalSession.url);
&nbsp;&nbsp;&nbsp; } catch (error) {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const message =
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; error instanceof Error
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ? error.message
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; : "Unknown error";
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; res.status(500).json({ error: message });
&nbsp;&nbsp;&nbsp; }
&nbsp; }
);
</code></pre>
<p>This route takes a <code>session_id</code> from a previous checkout, retrieves the associated customer, and creates a billing portal session. The <code>customer_account</code> field links the portal to the correct connected account so the customer sees only their subscriptions with that specific merchant.</p>
<p>Now add the JSON parser and mount the router. This must come <strong>after</strong> the webhook route:</p>
<pre><code class="language-typescript">// JSON and URL-encoded parsers (AFTER webhook route)
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// Mount all routes under /api
app.use('/api', router);
const PORT: number = parseInt(process.env.PORT || '4242', 10);
app.listen(PORT, () =&gt; {
  console.log(`Server running on port ${PORT}`);
});
</code></pre>
<h2 id="heading-how-to-build-the-nextjs-frontend"><strong>How to Build the Next.js Frontend</strong></h2>
<p>Navigate to the client directory and create a new Next.js project with TypeScript:</p>
<pre><code class="language-shell">cd ../client
npx create-next-app@latest . --typescript --app --tailwind --eslint
npm install axios
</code></pre>
<h2 id="heading-how-to-create-the-account-context"><strong>How to Create the Account Context</strong></h2>
<p>You need a way to share the merchant’s account ID across all components. Create a context provider at <code>contexts/AccountContext.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
import { useSearchParams } from 'next/navigation';

interface AccountContextType {
  accountId: string | null;
  setAccountId: (id: string | null) =&gt; void;
}

const AccountContext = createContext&lt;AccountContextType | undefined&gt;(undefined);

export function useAccount(): AccountContextType {
  const context = useContext(AccountContext);
  if (!context) {
    throw new Error('useAccount must be used within AccountProvider');
  }
  return context;
}

export function AccountProvider({ children }: { children: ReactNode }) {
  const searchParams = useSearchParams();
  const [accountId, setAccountId] = useState&lt;string | null&gt;(searchParams.get('accountId'));

  return (
    &lt;AccountContext.Provider value={{ accountId, setAccountId }}&gt;
      {children}
    &lt;/AccountContext.Provider&gt;
  );
}
</code></pre>
<p>This context stores the current merchant’s account ID and makes it available throughout the app. On initial load, it checks the URL for an accountId query parameter — this is how Stripe’s onboarding redirect passes the account ID back to your app.</p>
<h2 id="heading-how-to-create-the-account-status-hook"><strong>How to Create the Account Status Hook</strong></h2>
<p>Create a custom hook at <code>hooks/useAccountStatus.ts</code> that polls the account status:</p>
<pre><code class="language-typescript">'use client';
import { useState, useEffect } from 'react';
import { useAccount } from '@/contexts/AccountContext';
interface AccountStatus {
  id: string;
  payoutsEnabled: boolean;
  chargesEnabled: boolean;
  detailsSubmitted: boolean;
}
export default function useAccountStatus() {
  const [accountStatus, setAccountStatus] = useState&lt;AccountStatus | null&gt;(null);
  const { accountId, setAccountId } = useAccount();
  useEffect(() =&gt; {
    if (!accountId) return;
    const fetchStatus = async () =&gt; {
      try {
        const res = await fetch(`http://localhost:4242/api/account-status/${accountId}`);
        if (!res.ok) throw new Error('Failed to fetch');
        const data: AccountStatus = await res.json();
        setAccountStatus(data);
      } catch {
        setAccountId(null);
      }
    };
    fetchStatus();
    const interval = setInterval(fetchStatus, 5000);
    return () =&gt; clearInterval(interval);
  }, [accountId, setAccountId]);
  return {
    accountStatus,
    needsOnboarding: !accountStatus?.chargesEnabled &amp;&amp; !accountStatus?.detailsSubmitted,
  };
}
</code></pre>
<p>This hook polls the account status every 5 seconds. This is important because Stripe’s onboarding is asynchronous — a merchant might complete the form, but it can take a moment for Stripe to verify their details and activate their account. The <code>needsOnboarding</code> flag tells your UI whether to show the onboarding button or the merchant dashboard.</p>
<h2 id="heading-how-to-build-the-merchant-onboarding-component"><strong>How to Build the Merchant Onboarding Component</strong></h2>
<p>Create <code>components/ConnectOnboarding.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { useState } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
export default function ConnectOnboarding() {
  const [email, setEmail] = useState&lt;string&gt;('');
  const { accountId, setAccountId } = useAccount();
  const { accountStatus, needsOnboarding } = useAccountStatus();
  const handleCreateAccount = async () =&gt; {
    const res = await fetch(`${API_URL}/create-connect-account`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });
    const data = await res.json();
    setAccountId(data.accountId);
  };
  const handleStartOnboarding = async () =&gt; {
    const res = await fetch(`${API_URL}/create-account-link`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ accountId }),
    });
    const data = await res.json();
    window.location.href = data.url;
  };
  if (!accountId) {
    return (
      &lt;div className="max-w-md mx-auto p-6"&gt;
        &lt;h2 className="text-xl font-bold mb-4"&gt;Create Your Seller Account&lt;/h2&gt;
        &lt;input
          type="email"
          placeholder="Your email"
          value={email}
          onChange={(e) =&gt; setEmail(e.target.value)}
          className="w-full border p-2 rounded mb-4"
        /&gt;
        &lt;button
          onClick={handleCreateAccount}
          className="w-full bg-green-600 text-white p-2 rounded hover:bg-green-700"
        &gt;
          Create Connect Account
        &lt;/button&gt;
      &lt;/div&gt;
    );
  }
  return (
    &lt;div className="max-w-md mx-auto p-6"&gt;
      &lt;h3 className="font-semibold mb-2"&gt;Account: {accountId} &lt;/h3&gt;
      &lt;p className="mb-2"&gt;Charges: {accountStatus?.chargesEnabled ? 'Active' : 'Pending'} &lt;/p&gt;
      &lt;p className="mb-4"&gt;Payouts: {accountStatus?.payoutsEnabled ? 'Active' : 'Pending'} &lt;/p&gt;
      {needsOnboarding &amp;&amp; (
        &lt;button
          onClick={handleStartOnboarding}
          className="bg-purple-600 text-white px-6 py-2 rounded hover:bg-purple-700"
        &gt;
          Complete Onboarding
        &lt;/button&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component handles both states of the merchant experience. If no account exists, it shows a simple email form. After account creation, it shows the account status and an onboarding button if needed.</p>
<h2 id="heading-how-to-build-the-product-create-product-list-and-checkout"><strong>How to Build the Product Create, Product List and Checkout</strong></h2>
<p>Create <code>components/Products.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { useState, useEffect } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
interface Product {
  id: string;
  name: string;
  description: string | null;
  price: number | null;
  priceId: string;
  period: string | null;
}
export default function Products() {
  const { accountId } = useAccount();
  const { needsOnboarding } = useAccountStatus();
  const [products, setProducts] = useState&lt;Product[]&gt;([]);
  useEffect(() =&gt; {
    if (!accountId || needsOnboarding) return;
    const fetchProducts = async () =&gt; {
      const res = await fetch(`\({API_URL}/products/\){accountId}`);
      const data: Product[] = await res.json();
      setProducts(data);
    };
    fetchProducts();
    const interval = setInterval(fetchProducts, 5000);
    return () =&gt; clearInterval(interval);
  }, [accountId, needsOnboarding]);
  return (
    &lt;div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6"&gt;
      {' '}
      {products.map((product) =&gt; (
        &lt;div key={product.priceId} className="border rounded-lg p-4 shadow-sm"&gt;
          &lt;h3 className="text-lg font-semibold"&gt;&nbsp; {product.name}&lt;/h3&gt;

          &lt;p className="text-gray-600 mt-1"&gt;&nbsp; {product.description}&lt;/p&gt;

          &lt;p className="text-xl font-bold mt-3"&gt;
            ${((product.price ?? 0) / 100).toFixed(2)}
            {product.period ? ` / ${product.period}` : ''}
          &lt;/p&gt;

          &lt;form action={`${API_URL}/create-checkout-session`} method="POST"&gt;
            &lt;input type="hidden" name="priceId" value={product.priceId} /&gt;
            &lt;input type="hidden" name="accountId" value={accountId ?? ''} /&gt;
            &lt;button
              type="submit"
              className="mt-4 w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700"
            &gt;
              {product.period ? 'Subscribe' : 'Buy Now'}
            &lt;/button&gt;
          &lt;/form&gt;
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  );
}

</code></pre>
<p>The Products component fetches all products from the merchant’s Stripe account and displays them in a responsive grid. The checkout button submits a form directly to your backend, which redirects the customer to Stripe’s hosted checkout page. Notice how the button text changes based on whether the product is a one-time purchase or a subscription.</p>
<h2 id="heading-how-to-build-the-product-form"><strong>How to Build the Product Form</strong></h2>
<p>Merchants need a way to add products from the frontend. Create <code>components/ProductForm.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { useState } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
interface ProductFormData {
  productName: string;
  productDescription: string;
  productPrice: number;
}
export default function ProductForm() {
  const { accountId } = useAccount();
  const { needsOnboarding } = useAccountStatus();
  const [showForm, setShowForm] = useState&lt;boolean&gt;(false);
  const [formData, setFormData] = useState&lt;ProductFormData&gt;({
    productName: '',
    productDescription: '',
    productPrice: 1000,
  });
  const handleSubmit = async (e: React.FormEvent): Promise&lt;void&gt; =&gt; {
    e.preventDefault();
    if (!accountId || needsOnboarding) return;
    await fetch(`${API_URL}/create-product`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        ...formData,
        accountId,
      }),
    }); // Reset form and hide it
    setFormData({
      productName: '',
      productDescription: '',
      productPrice: 1000,
    });
    setShowForm(false);
  }; // Only show the form if the merchant has completed
  // onboarding and can accept charges
  if (!accountId || needsOnboarding) return null;
  return (
    &lt;div className="my-6"&gt;
      &lt;button
        onClick={() =&gt; setShowForm(!showForm)}
        className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
      &gt;
        {showForm ? 'Cancel' : 'Add New Product'}
      &lt;/button&gt;

      {showForm &amp;&amp; (
        &lt;form onSubmit={handleSubmit} className="mt-4 max-w-md space-y-4"&gt;
          &lt;div&gt;
            &lt;label className="block text-sm font-medium mb-1"&gt;Product Name&lt;/label&gt;

            &lt;input
              type="text"
              value={formData.productName}
              onChange={(e) =&gt;
                setFormData({
                  ...formData,
                  productName: e.target.value,
                })
              }
              className="w-full border p-2 rounded"
              required
            /&gt;
          &lt;/div&gt;

          &lt;div&gt;
            &lt;label className="block text-sm font-medium mb-1"&gt;Description&lt;/label&gt;
            &lt;input
              type="text"
              value={formData.productDescription}
              onChange={(e) =&gt;
                setFormData({
                  ...formData,
                  productDescription: e.target.value,
                })
              }
              className="w-full border p-2 rounded"
            /&gt;
          &lt;/div&gt;
          &lt;div&gt;
            &lt;label className="block text-sm font-medium mb-1"&gt;Price (in cents)&lt;/label&gt;

            &lt;input
              type="number"
              value={formData.productPrice}
              onChange={(e) =&gt;
                setFormData({
                  ...formData,
                  productPrice: parseInt(e.target.value),
                })
              }
              className="w-full border p-2 rounded"
              required
            /&gt;
          &lt;/div&gt;
          &lt;button
            type="submit"
            className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
          &gt;
            Create Product
          &lt;/button&gt;
        &lt;/form&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component only renders after the merchant has completed onboarding (the <code>if (!accountId || needsOnboarding) return null</code> check at the top). It toggles a form where the merchant enters a product name, description, and price in cents. When submitted, it calls your <code>/api/create-product</code> endpoint, which creates both the product and its price on the merchant’s connected Stripe account.</p>
<p>The price field uses cents because that is what Stripe expects. So if a merchant wants to sell a product for \(25.00, they enter 2500. In a production app, you would add a friendlier input that lets merchants type \)25.00 and converts it to cents automatically.</p>
<h2 id="heading-how-to-build-the-main-page"><strong>How to Build the Main Page</strong></h2>
<p>Finally, put it all together in <code>app/page.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { AccountProvider } from '@/contexts/AccountContext';
import ConnectOnboarding from '@/components/ConnectOnboarding';
import Products from '@/components/Products';
import ProductForm from '@/components/ProductForm';
export default function Home() {
  return (
    &lt;AccountProvider&gt;
      {' '}
      &lt;main className="max-w-6xl mx-auto p-8"&gt;
        &lt;h1 className="text-3xl font-bold mb-8"&gt; Marketplace Dashboard &lt;/h1&gt;
        &lt;ConnectOnboarding /&gt;
        &lt;ProductForm /&gt;
        &lt;Products /&gt;
      &lt;/main&gt;
    &lt;/AccountProvider&gt;
  );
}
</code></pre>
<h2 id="heading-how-to-test-the-full-flow"><strong>How to Test the Full Flow</strong></h2>
<p>Start both servers:</p>
<pre><code class="language-shell"># Terminal 1 - Backend
cd server
npm run dev
&nbsp;
# Terminal 2 - Frontend
cd client
npm run dev
&nbsp;
# Terminal 3 - Stripe webhook listener
stripe listen --forward-to localhost:4242/api/webhook
</code></pre>
<p>Now test the complete flow:</p>
<ol>
<li><p>Go to <a href="http://localhost:3000">http://localhost:3000</a> and enter an email to create a merchant account.</p>
</li>
<li><p>Click "Complete Onboarding" and fill out Stripe’s test onboarding form. Use test data like 000-000-0000 for the phone number and 0000 for the last four digits of SSN.</p>
</li>
<li><p>Wait a few seconds for the account status to update. Once charges are active, you can add products.</p>
</li>
<li><p>Create a product using the product form (set the price in cents — for example, 2500 for $25.00).</p>
</li>
<li><p>Click "Buy Now" on a product to start the checkout flow.</p>
</li>
<li><p>On Stripe’s checkout page, use the test card number 4242 4242 4242 4242 with any future expiry date and any CVC.</p>
</li>
<li><p>Check your terminal — you should see the webhook event confirming the payment.</p>
</li>
<li><p>Check the Stripe Dashboard to see the payment, the application fee, and the transfer to the connected account.</p>
</li>
</ol>
<h2 id="heading-how-the-payment-split-works"><strong>How the Payment Split Works</strong></h2>
<p>Here is exactly what happens when a customer pays $25.00 for a product:</p>
<ol>
<li><p>The customer pays $25.00 on Stripe’s checkout page.</p>
</li>
<li><p>Stripe deducts its processing fee (approximately 2.9% + $0.30 for US cards).</p>
</li>
<li><p>Your platform takes the application fee you set ($1.23 in our example).</p>
</li>
<li><p>The remaining amount is transferred to the merchant’s connected Stripe account.</p>
</li>
<li><p>The merchant can withdraw their funds to their bank account from the Stripe Dashboard.</p>
</li>
</ol>
<p>You control the application fee in the checkout route. In a production marketplace, you would calculate this as a percentage of the transaction. For example, to take a 10% fee:</p>
<pre><code class="language-plaintext">onst applicationFee = Math.round(
&nbsp; (price.unit_amount ?? 0) * 0.1
);
</code></pre>
<h2 id="heading-next-steps"><strong>Next Steps</strong></h2>
<p>You now have a working marketplace. Here are improvements to consider for production:</p>
<ul>
<li><p>Add authentication with NextAuth.js so merchants can securely log in and manage their accounts across sessions.</p>
</li>
<li><p>Add runtime validation with Zod to validate all request bodies before they reach Stripe.</p>
</li>
<li><p>Add image uploads for products using Cloudinary or AWS S3, then pass the image URL to Stripe’s product metadata.</p>
</li>
<li><p>Build separate merchant and customer views. Right now the app combines both experiences on one page.</p>
</li>
<li><p>Deploy your backend to Railway or Render and your frontend to Vercel. Update the webhook URL in your Stripe Dashboard to point to your production server.</p>
</li>
</ul>
<p>You can find the complete source code for this tutorial on GitHub: <a href="https://github.com/michaelokolo/marketplace">https://github.com/michaelokolo/marketplace</a></p>
<h2 id="heading-acknowledgements"><strong>Acknowledgements</strong></h2>
<p>Some API usage patterns in this tutorial are inspired by examples from the <a href="https://docs.stripe.com">official Stripe documentation</a>. These examples were adapted to demonstrate how to build a complete multi-vendor marketplace architecture.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>In this handbook, you built a complete online marketplace where merchants can onboard through Stripe Connect, create products stored directly in Stripe, and receive payments from customers — all without a traditional database.</p>
<p>You learned how to use Stripe’s V2 Accounts API for merchant onboarding, create products and prices on connected accounts, build a checkout flow that handles both one-time payments and subscriptions, listen for payment events with webhooks, and give customers a billing portal to manage their subscriptions.</p>
<p>The key insight is that Stripe Connect handles the hardest parts of running a marketplace — payment splitting, tax compliance, identity verification, and fund transfers. Your job is to build a great user experience on top of it.</p>
<p>If you found this tutorial helpful, share it with someone who is learning to build full-stack applications. Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The WebCodecs Handbook: Native Video Processing in the Browser  ]]>
                </title>
                <description>
                    <![CDATA[ If you've ever tried to process video in the browser, like for a video editing or streaming app, your options were either to process video on a server (expensive) or to use ffmpeg.js (clunky). With th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-webcodecs-handbook-native-video-processing-in-the-browser/</link>
                <guid isPermaLink="false">69d6bc21707c1ce7688010d3</guid>
                
                    <category>
                        <![CDATA[ video ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ HTML5 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #WebCodecs  ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Sam Bhattacharyya ]]>
                </dc:creator>
                <pubDate>Wed, 08 Apr 2026 20:35:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9b0978d4-7d8c-464c-ade0-07d007f56d92.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've ever tried to process video in the browser, like for a video editing or streaming app, your options were either to process video on a server (expensive) or to use ffmpeg.js (clunky). With the WebCodecs API, there's now a better way to do this.</p>
<p>WebCodecs is a relatively new API that allows browser applications to process video efficiently with very low-level control.</p>
<p>In the past, if you wanted to build, say, a video-editing app or live-streaming studio or anything that required 'heavy lifting', you needed to build a native desktop application. Many SaaS tools like Canva got around this with server-side video processing, which provided a much better UX, but which is much more complex and expensive.</p>
<p>With WebCodecs, it's now possible to build these apps entirely in the browser, without requiring users to download and install software, and without expensive, complex server infrastructure.</p>
<p>This isn't theoretical. Video Editing tools like Capcut saw an 83% boost in traffic after switching to WebCodecs + WebAssembly [<a href="https://web.dev/case-studies/capcut?hl=en">1</a>]. Utility apps like <a href="https://www.remotion.dev/convert">Remotion Convert</a> and <a href="https://free.upscaler.video/">Free AI Video Upscaler</a> (both open source) process thousands of videos a day with zero server costs and no installation required [<a href="https://web.dev/case-studies/ai-video-upscaler-case-study">2</a>].</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/73f19be2-b746-421c-888f-7431962520d7.png" alt="Remotion Convert" style="display:block;margin:0 auto" width="630" height="566" loading="lazy">

<p>WebCodecs is even being used for entirely new use cases, like generating videos programatically [<a href="https://github.com/remotion-dev/remotion">3</a>].</p>
<p>If you're building any kind of video app, it's worthwhile to at least know about WebCodecs as an option for working with video in the browser.</p>
<p>In this guide, we will:</p>
<ol>
<li><p>Review the basics of Video Processing</p>
</li>
<li><p>Introduce the WebCodecs API</p>
</li>
<li><p>Discuss Muxing + Demuxing to read and write video files</p>
</li>
<li><p>Build our own video conversion utility to convert videos between webm + mp4, and apply basic transformations</p>
</li>
<li><p>Cover some production-level concerns</p>
</li>
<li><p>Discuss additional resources</p>
</li>
</ol>
<p>The goal of this article is to be a practical entry point and introduction to the <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API">WebCodecs API</a> for frontend developers. It'll teach you how the API works and what you can do with it. I'll assume you know the basics of Javascript but you don't need to be a senior developer or a video engineer to follow along.</p>
<p>At the end, I'll mention additional learning resources and references. In future tutorials, I'll go more in-depth on specific topics like building a video editor, or doing live-streaming with WebCodecs. But this handbook should provide a solid starting point for what WebCodecs is, what it can do, and how to build a basic application with it.</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-primer-on-video-processing">Primer on Video Processing</a></p>
<ul>
<li><p><a href="#heading-video-frames">Video Frames</a></p>
</li>
<li><p><a href="#heading-codecs">Codecs</a></p>
</li>
<li><p><a href="#heading-encoding-amp-decoding">Encoding &amp; Decoding</a></p>
</li>
<li><p><a href="#heading-containers">Containers</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-is-webcodecs">What is WebCodecs?</a></p>
<ul>
<li><p><a href="#heading-before-webcodecs">Before WebCodecs</a></p>
</li>
<li><p><a href="#heading-core-api">Core API</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-muxing-and-demuxing">Muxing and Demuxing</a></p>
<ul>
<li><p><a href="#heading-demuxing">Demuxing</a></p>
</li>
<li><p><a href="#heading-muxing">Muxing</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-building-a-video-converter-utility">Building a Video Converter Utility</a></p>
<ul>
<li><p><a href="#heading-transcoding">Transcoding</a></p>
</li>
<li><p><a href="#heading-transformations">Transformations</a></p>
</li>
<li><p><a href="#heading-transform-pipeline">Transform Pipeline</a></p>
</li>
<li><p><a href="#heading-complete-demo">Complete Demo</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-production-concerns">Production Concerns</a></p>
<ul>
<li><p><a href="#heading-codecs-1">Codecs</a></p>
</li>
<li><p><a href="#heading-bit-rate">Bit rate</a></p>
</li>
<li><p><a href="#heading-gpu-vs-cpu">GPU vs CPU</a></p>
</li>
<li><p><a href="#heading-memory">Memory</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-further-resources">Further Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You don't need to be a video engineer to follow along, but you should be comfortable with:</p>
<ul>
<li><p>Core JavaScript, including async/await and callbacks</p>
</li>
<li><p>Basic browser APIs like fetch and the DOM</p>
</li>
<li><p>What a File object is and how file inputs work in HTML</p>
</li>
<li><p>A general sense of what HTML5 is (we'll use it briefly, but won't go deep)</p>
</li>
</ul>
<p>No prior knowledge of video processing, codecs, or media APIs is required — that's what the first half of this handbook covers.</p>
<h2 id="heading-primer-on-video-processing">Primer on Video Processing</h2>
<p>Hold your bunnies, because before getting into WebCodecs, I want to make sure you're aware of what codecs are before we even consider putting codecs on the web.</p>
<h3 id="heading-video-frames">Video Frames</h3>
<p>I presume you know what a video is. Ironically the 'video' below is actually a gif, but you get the idea.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/0e95c1f7-d384-4065-bba9-4dade19ce6f8.gif" alt="Big Buck Bunny, an opensource video" style="display:block;margin:0 auto" width="320" height="180" loading="lazy">

<p>Videos are just a series of images, shown one after the other, in quick succession. Each image is called a <em>Video Frame</em>, and each frame is associated with a timestamp. When a video player plays back the video, it displays each video frame at the time indicated by the timestamp.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/26f534e8-f828-41ec-9bcd-de059916f528.png" alt="Video Frames" style="display:block;margin:0 auto" width="540" height="360" loading="lazy">

<p>Every frame in the video is made of pixels, with a 4K video frame containing approximately 8 million pixels (3840*2160 = 8294400).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/2c73397d-2d9f-4091-8b32-fa7a5dac115f.png" alt="VideoFrames have pixels" style="display:block;margin:0 auto" width="960" height="540" loading="lazy">

<p>Each pixel itself is actually made of 3 components: a Red, Green, and Blue value (also called RGB value).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/2fc2b555-265b-4d6a-8670-981e197c6566.svg" alt="RGB Channels" style="display:block;margin:0 auto" width="1152" height="288" loading="lazy">

<p>Each of of the R, G and B color values is stored as an 8-bit integer, ranging from 0 to 255, with the number indicating the intensity of the red, green, or blue color component.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/d9d7ae0b-e67b-4cbb-b21a-867108cd3303.png" alt="uint8 color channel" style="display:block;margin:0 auto" width="960" height="384" loading="lazy">

<p>Combining the intensity of each of the R, G, and B components lets you represent any arbitrary color on the color spectrum:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/adbeb63b-20b8-4ddb-9b7f-83c88f86e09d.png" alt="RGB Color value examples" style="display:block;margin:0 auto" width="1152" height="1152" loading="lazy">

<p>So for each pixel, we need 3 bytes of data: 1 byte for each of the R, G, and B color values (1 byte = 8 bits). A 4K video frame therefore would contain ~25 Megabytes of data.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/2fc2b555-265b-4d6a-8670-981e197c6566.svg" alt="RGB Channnels" style="display:block;margin:0 auto" width="1152" height="288" loading="lazy">

<p>At 30 frames per second (a typical frame rate), a 1 hour, 4K video would be around <strong>746 Gigabytes of data</strong>. If you've ever downloaded a large video or recorded HD video with your phone camera, you'll know that video files can be large, but they're never <em>that</em> large.</p>
<p>In reality, actual video files you might watch on YouTube, record on your phone camera, or download from the internet are ~100x smaller than that. The reason actual video files are much smaller is because of <em>video compression</em>, a family of very sophisticated algorithms that help reduce the data by ~100x.</p>
<p>Without this video compression, you wouldn't be able to record more than 10 minutes of video on the latest high-end smartphones, and you wouldn't be able to stream anything HD on a high-end home internet connection.</p>
<p>As sophisticated as our modern devices and internet connections are, without aggressive video compression, we wouldn't be able to watch, record, or stream anything in HD.</p>
<h3 id="heading-codecs">Codecs</h3>
<p>A <em>codec</em> is a fancy word for a video compression algorithm. There are a few established codecs / compression algorithms, such as:</p>
<ul>
<li><p><code>h264</code>: The most common codec. If you see an mp4 file, it most likely uses the h264 codec.</p>
</li>
<li><p><code>vp9</code>: An open source codec used commonly by YouTube and in video conferencing, often found in webm files.</p>
</li>
<li><p><code>av1</code>: A new open source codec, increasingly being used by platforms like YouTube and Netflix.</p>
</li>
</ul>
<p>How these algorithms work is too complex and out of scope for this handbook. But at a very high level, here are some major ways these algorithms compress video:</p>
<h4 id="heading-removing-detail">Removing detail</h4>
<p>All these algorithms use a technique called the Discrete Cosine Transform to "remove details". As you remove "detail" from the video frame, the frame starts looking "blockier". This technique is so effective, though, that you can compress a video frame by ~10x before the differences start becoming visible to the human eye.</p>
<p>For the curious, you can see <a href="https://www.youtube.com/watch?v=n_uNPbdenRs">this video</a> by Computerphile on how the DCT algorithm works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/f2b3eac4-c36a-4943-a176-4f2af72cfbac.png" alt="DCT algorithm removing details" style="display:block;margin:0 auto" width="1920" height="480" loading="lazy">

<h4 id="heading-encoding-frame-differences">Encoding frame differences</h4>
<p>When you actually look at a sequence of video frames, you'll notice that visually they're quite similar, with only small portions of the video changing, depending on how much movement there is.</p>
<p>These codecs/compression algorithms use sophisticated math and computer vision techniques to encode just the differences between frames,.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/40d309dd-84fc-4018-8da4-1438bbfb8df6.png" alt="Frame Differences" style="display:block;margin:0 auto" width="960" height="288" loading="lazy">

<p>You therefore only need to send the first frame (a <em>Key Frame</em>) – then for subsequent frames you can send the "frame differences", also called <em>Delta Frames</em>, to reconstruct the each full frame.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/66bbe03f-c394-477d-a3c2-64b571c39348.png" alt="Key Frames vs Delta frames" style="display:block;margin:0 auto" width="960" height="288" loading="lazy">

<p>In practice, for an hour long video, we don't just encode the first frame and store millions of delta frames. Instead, algorithms encode every 60th frame or so as a Key Frame, and then the next 59 frames are delta frames.</p>
<p>This technique is also highly effective, reducing data used by another ~10x. The distinction between <em>Key Frames</em> and <em>Delta Frames</em> is one of the few bits of "how these algorithms work" that you actually need to be aware of.</p>
<p>There's a number of other details and compression techniques that go into these compression algorithms that are out of scope for an intro article.</p>
<h3 id="heading-encoding-amp-decoding">Encoding &amp; Decoding</h3>
<p>For video compression to work, we need to be able to both compress video (turn raw video into compressed binary data) and then decompress video (turn the compressed binary data back into raw video frames).</p>
<p>Turning raw video frames into compressed binary data is called <em>encoding</em>, and turning compressed binary data back into raw video frames is called <em>decoding.</em> The word <em>codec</em> is just an abbreviation for "encode decode".</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/843e75e0-4453-4e3e-91d5-a441f1a8e2ff.png" alt="VideoEncoder and VideoDecoder" style="display:block;margin:0 auto" width="960" height="384" loading="lazy">

<p>From a practical, developer perspective, you don't need to know how these codecs work, but you do need to know that:</p>
<ol>
<li><p>There are different video codecs, like <code>h264</code>, <code>vp9</code>, and <code>av1</code></p>
</li>
<li><p>When you encode a video with a codec (like <code>h264</code>), you need a video player that can support the same codec to play back the video.</p>
</li>
<li><p>Encoding video takes a lot more computation than decoding video, so playing 4K video on a low-end phone is fine, but encoding 4K video on it would be super slow.</p>
</li>
<li><p>Most consumer devices (phones, laptops) have specialized chips designed specifically for encoding and decoding video, making encoding/decoding much faster than if run on the CPU like a normal software program. This is called <em>hardware acceleration.</em></p>
</li>
</ol>
<p>In practice, there are only a handful of video codecs, because the entire world needs to agree on standards, so that video recorded on an iPhone can be played back on a windows device.</p>
<h3 id="heading-containers">Containers</h3>
<p>Most people haven't heard of <code>h264</code> or <code>vp9</code>. When you think of video files, you typically think of file formats like MP4 or MKV. These are also relevant, but they're a separate thing called containers.</p>
<p>A video file typically has encoded audio, encoded video, and metadata about the video file. A file format like MP4 describes a specific format for storing the encoded audio and video data, as well as the metadata.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/961e7177-e331-4aa2-acdc-9d6732f18c56.png" alt="Video Container" style="display:block;margin:0 auto" width="624" height="720" loading="lazy">

<p>Video compression software stores the encoded audio/video and metadata into a file according to the file format / specs. This is called <em>muxing.</em></p>
<p>Likewise, video players follow the file format specs to read the metadata and find the encoded audio/video. This is called <em>demuxing</em>.</p>
<p>When compressing a video file, you need to both <em>encode</em> it and <em>mux</em> it (in that order). These are two separate stages of the process. Likewise, when playing a video file, you need to both <em>demux</em> it and then <em>decode</em> it (in that order).</p>
<p>When a video player opens, say, an mp4 file, the logic flow is as follows:</p>
<ul>
<li><p>Ok, the file ends in .mp4, so it must be an mp4 file. Let me load the library for parsing mp4 files, and parse then parse file.</p>
</li>
<li><p>Great, I've parsed the mp4 file, I now have the metadata and know where in the byte offsets are to fetch the encoded audio and video.</p>
</li>
<li><p>I'll start fetching the first encoded video frames, decode them, and start displaying the decoded video frame to the user.</p>
</li>
</ul>
<p>If you ever see a "video file is corrupt" message from a video player, it's likely that the video file doesn't follow the file format spec and there was an error while trying the parse / demux the video.</p>
<h2 id="heading-what-is-webcodecs">What is WebCodecs?</h2>
<p>Now that we've covered codecs, let's put them on the Web.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/3fc9d220-f397-479a-9517-6b8dc94aaa6b.png" alt="WebCodecs = Web + Codecs" style="display:block;margin:0 auto" width="960" height="288" loading="lazy">

<p>WebCodecs is an API that allows frontend developers to encode and decode video in the browser efficiently (using hardware acceleration), and with very low level control (encode/decode on a frame by frame basis).</p>
<p>The hardware acceleration bit is important, as you can't just poly fill or re-implement the API yourself. WebCodecs gives direct access to specialized hardware for encoding/decoding, making it as performant as a desktop video app.</p>
<h3 id="heading-before-webcodecs">Before WebCodecs</h3>
<p>It's worth taking a moment to understand why WebCodecs exists. Before the WebCodecs API existed, there were several alternatives you could use for video operations in the browser.</p>
<ul>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement">HTMLVideoElement</a>: You can still create a element and use it for decoding a video. It's easy to use, but you lack frame level control. Your only control is setting the 'video.currrentTime' property and waiting for it to seek, often leading to dropped/missing frames.</p>
</li>
<li><p><a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder">Media Recorder API</a>: Essentially allows you to 'screen record' any canvas element or video stream. While it works, it's functionally equivalent to screen recording Adobe Premeire pro instead of clicking render. For editing scenarios, you lose frame level control and can only process video at real-time speed.</p>
</li>
<li><p><a href="https://github.com/Kagami/ffmpeg.js/">FFMPEG.js</a>: A port of the popular video processing tool ffmpeg, which runs ffmpeg in the browser. Many tools used this in the past, but it lacks hardware acceleration, making it much slower than WebCodecs. It also has file size restrictions stemming from the fact that it runs in WebAssembly, making it difficult to work with videos that are larger than 100 MB.</p>
</li>
</ul>
<p>WebCodecs was built and released in 2021 to enable low-level, hardware accelerated video decoding and encoding. It's great for high-performance streaming and video editing, which were use cases not well-served by the existing APIs.</p>
<h3 id="heading-core-api">Core API</h3>
<p>The core API for WebCodecs consists of two new "data types", the <em>VideoFrame</em> and <em>EncodedVideoChunk</em>, as well as the <em>VideoEncoder</em> and <em>VideoDecoder</em> interfaces.</p>
<h4 id="heading-videoframe">VideoFrame</h4>
<p>The Javascript <em>VideoFrame</em> object conceptually contains both pixel data and metadata about the video frame.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/bd341249-fab6-4c76-85b4-dd21d8de10f6.svg" alt="VideoFrame object" style="display:block;margin:0 auto" width="1920" height="816" loading="lazy">

<p>You can actually create a new <em>VideoFrame</em> object from any image source, as long as you include the metadata:</p>
<pre><code class="language-javascript">const bitmapFrame = new VideoFrame(imgBitmap, {timestamp: 0});

const imageFrame = new VideoFrame(htmlImageEl, {timestamp: 0});

const videoFrame = new VideoFrame(htmlVideoEl, {timestamp: 0});

const canvasFrame = new VideoFrame(canvasEl, {timestamp: 0});
</code></pre>
<p>For a video editing app, for example, you would typically perform image editing operations on each frame on a canvas, and then you would grab each <em>VideoFrame</em> from the canvas.</p>
<p>You can also draw a <em>VideoFrame</em> to a canvas using the <a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D">Canvas 2D rendering context</a>:</p>
<pre><code class="language-typescript">ctx.drawImage(frame, 0, 0);
</code></pre>
<p>You would typically do this when rendering / playing back a video in the browser.</p>
<h4 id="heading-encodedvideochunk">EncodedVideoChunk</h4>
<p>An <em>EncodedVideoChunk</em> is just the compressed version of a <em>VideoFrame,</em> containing the binary data as well as the same metadata as the frame.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/d20b9306-03ae-48a8-b10a-f462f4a620e2.svg" alt="EncodedVideoChunk" style="display:block;margin:0 auto" width="1728" height="816" loading="lazy">

<p>You would typically get <em>EncodedVideoChunks</em> from a library which extracts them from a <em>File</em> object.</p>
<pre><code class="language-typescript">import { getVideoChunks } from 'webcodecs-utils'

const chunks = &lt;EncodedVideoChunk[]&gt; await getVideoChunks(&lt;File&gt; file);
</code></pre>
<p>Alternatively, it's the output you get from a <em>VideoEncoder</em> object.</p>
<p>There's not much useful stuff you can do with <em>EncodedVideoChunks</em> – it's just the binary data that you read from files, write to files, or stream over the internet.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/c5778116-44ec-4fb7-8f35-6ebd2f2a8d15.gif" alt="Video streaming with encode and decode" style="display:block;margin:0 auto" width="800" height="560" loading="lazy">

<p>The value in <em>EncodedVideoChunk</em> is that it's ~100x smaller than raw video data, which is why you'd send <em>EncodedVideoChunks</em> instead of raw video when streaming (and writing to a file).</p>
<h4 id="heading-videoencoder">VideoEncoder</h4>
<p>A <em>VideoEncoder</em> turns <em>VideoFrame</em> objects into <em>EncodedVideoChunk</em> objects.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/612a6f6a-04c6-4f24-aebd-074f232cd06e.svg" alt="VideoEncoder" style="display:block;margin:0 auto" width="960" height="384" loading="lazy">

<p>The core API looks something like this, where you define the callback where the <em>VideoEncoder</em> returns <em>EncodedVideoChunk</em> objects.</p>
<pre><code class="language-typescript">const encoder = new VideoEncoder({
    output: function(chunk: EncodedVideoChunk, meta: any){
        // Do something with the chunk
    },
    error: function(e: any)=&gt; console.warn(e);
});
</code></pre>
<p>Keep in mind that this is an async process, and not even a typical async process. You can't just treat this as a per-frame operation.</p>
<pre><code class="language-javascript">// Does not work like this
const frame  = await encoder.encode(chunk);
</code></pre>
<p>This is because of how video encoding actually works under the hood. So you have to accept that the outputs are returned via callback, and you get the outputs when you get them.</p>
<p>Once you define your encoder, you can then configure the <em>VideoEncoder</em> with your choice of codec (we'll get to this), as well as other parameters like width, height, framerate and bitrate.</p>
<pre><code class="language-typescript">encoder.configure({
    'codec': 'vp9.00.10.08.00', // We'll get to this
     width: 1280,
     height: 720,
     bitrate: 1000000 //1 MBPS,
     framerate: 25
});
</code></pre>
<p>You can then start encoding frames. Here we assume we already have <em>VideoFrame</em> objects, and we make every 60th frame a <em>Key Frame</em>.</p>
<pre><code class="language-typescript">for (let i=0; i &lt; frames.length; frames++){
    encoder.encode(frames[i], {keyFrame: i%60 ==0})
}
</code></pre>
<h4 id="heading-videodecoder">VideoDecoder</h4>
<p>The Video Decoder does the reverse, turning <em>EncodedVideoChunk</em> objects into <em>VideoFrame</em> objects.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/47258dd5-0a7b-479a-8729-6f384ff8bc4b.svg" alt="VideoDecoder" style="display:block;margin:0 auto" width="960" height="384" loading="lazy">

<p>Here's a simplified example of how to set up the <em>VideoDecoder.</em> First, extract the <em>EncodedVideoChunk</em> objects and the decoder config from the video file. Here, we don't choose the config&nbsp;– the config was chosen by whoever encoded the file. When decoding, we extract the config from the file.</p>
<pre><code class="language-typescript">import { demuxVideo } from 'webcodecs-utils';

const {chunks, config} = await demuxVideo(&lt;File&gt; file);
</code></pre>
<p>Next, we set up the <em>VideoDecoder</em> by specifying the callback when <em>VideoFrame</em> objects are generated, and we configure it with the config.</p>
<pre><code class="language-typescript">const decoder = new VideoDecoder({
    output: function(frame: VideoFrame){
        //do something with the VideoFrame
    },
    error: function(e: any)=&gt; console.warn(e);
});

decoder.configure(config)
</code></pre>
<p>Again, like with <em>VideoEncoder</em>, it returns frames in a callback. Finally we can start decoding chunks.</p>
<pre><code class="language-typescript">for (const chunk of chunks){
    decoder.decode(chunk);
}
</code></pre>
<h4 id="heading-putting-it-all-together">Putting it all together</h4>
<p>At its core, the WebCodecs API is just the two data types (<em>EncodedVideoChunk, VideoFrame)</em> and the <em>VideoEncoder</em> and <em>VideoDecoder</em> interfaces which convert between the two data types.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/444553ad-58af-4b0e-ba46-d8910d3d548b.svg" alt="The core of WebCodecs" style="display:block;margin:0 auto" width="960" height="384" loading="lazy">

<p>Keep in mind that the WebCodecs API doesn't actually work with video files. It only applies the encoding and decoding, and <em>EncodedVideoChunk</em> objects just represent binary data.</p>
<p>Reading video files and writing video files are their own, separate thing called muxing/demuxing.</p>
<h2 id="heading-muxing-and-demuxing">Muxing and Demuxing</h2>
<p>To write to a video file, you'll also need to <em>mux</em> the video. And to play a video file, you need to <em>demux</em> the video. This involves following the file format of the video container, parsing the video file (in the case of demuxing), or placing encoded video data in the right place in the file you are writing to (muxing).</p>
<p>Muxing and Demuxing are not included in the WebCodecs API, so you'll need to use a separate library to handle muxing and demuxing.</p>
<h3 id="heading-demuxing">Demuxing</h3>
<p>To play a video back in the browser, we need to both <em>demux</em> the video and <em>decode</em> the video, in that order.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/6e0459e9-f960-43dd-a1e3-a214091c439e.png" alt="Demuxing and decoding" style="display:block;margin:0 auto" width="960" height="720" loading="lazy">

<p>There are several libraries you can use to demux videos, including <a href="http://mediabunny.dev/">MediaBunny</a> or <a href="https://github.com/bilibili/web-demuxer">web-demuxer</a>. For the purposes of this tutorial, I put a very simplified wrapper around these libraries and exposed it in the <a href="https://www.npmjs.com/package/webcodecs-utils">webcodecs-utils</a> package, so that demuxing is a very simple 2-liner:</p>
<pre><code class="language-typescript">import { demuxVideo } from 'webcodecs-utils'
const {chunks, config} = await demuxVideo(file);
</code></pre>
<p>This reads the entire video into memory, so don't do this in practice. But it's helpful in making a simple, readable hello world for WebCodecs.</p>
<p>The following snippet will take in a video file (<em>File</em> object), decode it, and paint the result to a canvas. Here, we get the frames from the output callback, and run the draw calls directly from the callback.</p>
<pre><code class="language-typescript">import { demuxVideo } from 'webcodecs-utils'

async function playFile(file: File){

    const {chunks, config} = await demuxVideo(file);
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    const decoder = new VideoDecoder({
        output(frame: VideoFrame) {
            ctx.drawImage(frame, 0, 0);
            frame.close()
        },
        error(e) {}
    });


    decoder.configure(config);

    for (const chunk of chunks){
        decoder.decode(chunk)
    }

}
</code></pre>
<p>Here's our super barebones demo for playing back an actual video:</p>
<div class="embed-wrapper"><iframe width="100%" height="350" src="https://codepen.io/Sam-Bhattacharyya/embed/OPRErmj" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="CodePen embed" scrolling="no" allowtransparency="true" allowfullscreen="true" loading="lazy"></iframe></div>

<p>For a more 'correct' demuxing example, here is what demuxing looks like with MediaBunny, where you can extract chunks in an iterative fashion.</p>
<pre><code class="language-typescript">import { EncodedPacketSink, Input, ALL_FORMATS, BlobSource } from 'mediabunny';

const input = new Input({
  formats: ALL_FORMATS,
  source: new BlobSource(&lt;File&gt; file),
});

const videoTrack = await input.getPrimaryVideoTrack();
const sink = new EncodedPacketSink(videoTrack);

for await (const packet of sink.packets()) {
  const chunk = &lt;EncodedVideoChunk&gt; packet.toEncodedVideoChunk();
}
</code></pre>
<h3 id="heading-muxing">Muxing</h3>
<p>To write a video file, you not only need to encode it (with the <em>VideoEncoder</em>) you also need to <em>mux</em> it. This involves taking the encoded chunks and placing them in the right place in the output binary file that you're writing to.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/176ef792-e761-46d5-badf-58fd08d78552.png" alt="Muxing and Encoding" style="display:block;margin:0 auto" width="960" height="624" loading="lazy">

<p>Again, you need a library to mux videos ( <a href="http://mediabunny.dev/">MediaBunny</a>), but for demo purposes I created a super simple wrapper. Here we define a super basic ExampleMuxer.</p>
<pre><code class="language-typescript">import { ExampleMuxer } from 'webcodecs-utils'

const muxer = new ExampleMuxer('video');

for (const chunk of encodedChunks){
    muxer.addChunk(chunk);
}

const outputBlob = await muxer.finish();
</code></pre>
<p>As a full encoding + muxing demo, we'll create an encoder, and we'll set it to mux the output encoded chunks as soon as they are returned.</p>
<pre><code class="language-typescript">const encoder = new VideoEncoder({
    output: function(chunk, meta){
        muxer.addChunk(chunk, meta);
    },
    error: function(e){}
})

encoder.configure({
    'codec': 'avc1.4d0034', // We'll get to this
     width: 1280,
     height: 720,
     bitrate: 1000000 //1 MBPS,
     framerate: 25
});
</code></pre>
<p>We'll then define a canvas animation, which will draw the current frame number to the screen, just to prove it's working.</p>
<pre><code class="language-typescript">const canvas = new OffscreenCanvas(640, 360);
const ctx = canvas.getContext('2d');
const TOTAL_FRAMES=300;
let frameNumber = 0;
let chunksMuxed = 0;
const fps = 30;


function renderFrame(){
    ctx.fillStyle = '#000';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = 'white';
    ctx.font = `bold ${Math.min(canvas.width / 10, 72)}px Arial`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(`Frame ${frameNumber}`, canvas.width / 2, canvas.height / 2);
}
</code></pre>
<p>Finally we'll create the encode loop, which will draw the current frame, and then encode it.</p>
<pre><code class="language-typescript">
let flushed = false;

async function encodeLoop(){

    renderFrame();

    const frame = new VideoFrame(canvas, {timestamp: frameNumber/fps*1e6});
    encoder.encode(frame, {keyFrame: frameNumber %60 ===0});
    frame.close();

    frameNumber++;

    if(frameNumber === TOTAL_FRAMES) {
        if (!flushed) encoder.flush();
    }
    else return requestAnimationFrame(encodeLoop);
}
</code></pre>
<p>Putting it all together, you can encode the canvas animation to a video file with frame-level accuracy.</p>
<div class="embed-wrapper"><iframe width="100%" height="350" src="https://codepen.io/Sam-Bhattacharyya/embed/KwgebEJ" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="CodePen embed" scrolling="no" allowtransparency="true" allowfullscreen="true" loading="lazy"></iframe></div>

<p>You can download the video and use any video inspection tool to verify that every single frame number is included.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/776d6db5-f67c-4538-8e7d-f61e35e698ce.png" alt="Videos with frame level accuracy" style="display:block;margin:0 auto" width="915" height="630" loading="lazy">

<p>This is one of the critical distinctions that separates this from other web APIs like <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder">MediaRecorder</a> which can also encode video, but has no frame-level accuracy. WebCodecs makes sure that you can control and guarantee the consistency of each frame.</p>
<p>Finally, a proper full, muxing example using MediaBunny would look like this:</p>
<pre><code class="language-typescript">import {
  EncodedPacket,
  EncodedVideoPacketSource,
  BufferTarget,
  Mp4OutputFormat,
  Output
} from 'mediabunny';

async function muxChunks(chunks: EncodedVideoChunk[]): Promise &lt;Blob&gt;{

    const output = new Output({
        format: new Mp4OutputFormat(),
        target: new BufferTarget(),
    });

    const source = new EncodedVideoPacketSource('avc');
    output.addVideoTrack(source);

    await output.start();

    for (const chunk of chunks){
        source.add(EncodedPacket.fromEncodedChunk(chunk))
    }

    await output.finalize();
    const buffer = &lt;ArrayBuffer&gt; output.target.buffer;
    return new Blob([buffer], { type: 'video/mp4' });

});
</code></pre>
<h2 id="heading-building-a-video-converter-utility">Building a Video Converter Utility</h2>
<p>Now that we've covered the basics of WebCodecs as well as Muxing, we'll move towards actually building an MVP of something useful: a video converter utility. We'll be able to use it to convert between mp4 and webm, and do some basic operations like resizing and flipping the video.</p>
<h3 id="heading-transcoding">Transcoding</h3>
<p>Before we do resizing and flipping, let's first handle a basic conversion decoding a video, and encoding the video to a new format. This is called transcoding.</p>
<p>To transcode video, we need to set up a pipeline with the following processes:</p>
<ul>
<li><p><strong>Demuxing</strong>: Read <em>EncodedVideoChunks</em> from a video file</p>
</li>
<li><p><strong>Decoding</strong>: Convert <em>EncodedVideoChunks</em> to <em>VideoFrames</em></p>
</li>
<li><p><strong>Encoding</strong>: Convert <em>VideoFrames</em> to new <em>EncodedVideoChunks</em></p>
</li>
<li><p><strong>Muxing</strong>: Write the <em>EncodedVideoChunks</em> to a new video file</p>
</li>
</ul>
<p>Our pipeline looks something like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/f06e8516-5498-44bd-9e68-e048afb303e9.png" alt="Transcoding pipeline" style="display:block;margin:0 auto" width="960" height="576" loading="lazy">

<p>Using everything we've covered in this article up until now, we could build a full working demo with just <em>VideoEncoder</em> and <em>VideoDecoder</em> as discussed. But then state management and tracking frames becomes complicated and error prone.</p>
<p>We're going to add one more abstraction, using the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Streams_API">Streams API</a>, which will make our pipeline look like the below. It ties directly to our mental model of our pipeline and simplifies a ton of details like state management.</p>
<pre><code class="language-javascript">const transcodePipeline = demuxerReader
    .pipeThrough(new VideoDecoderStream(videoDecoderConfig))
    .pipeThrough(new VideoEncoderStream(videoEncoderConfig))
    .pipeTo(createMuxerWriter(muxer));

await transcodePipeline;
</code></pre>
<p>To do this, we'll create a TransformStream for the <em>VideoDecoder</em> and <em>VideoEncoder.</em></p>
<pre><code class="language-typescript">class VideoDecoderStream extends TransformStream&lt;{ chunk: EncodedVideoChunk; index: number }, { frame: VideoFrame; index: number }&gt; {
  constructor(config: VideoDecoderConfig) {
    let pendingIndices: number[] = [];
    super(
      {
        start(controller) {
          decoder = new VideoDecoder({
            output: (frame) =&gt; {
              const index = pendingIndices.shift()!;
              controller.enqueue({ frame, index });
            },
            error: (e) =&gt; controller.error(e),
          });

          decoder.configure(config);
        },

        async transform(item, controller) {
          pendingIndices.push(item.index);
          decoder.decode(item.chunk);
        },

        async flush(controller) {
          await decoder.flush();
          if decoder.state !== 'closed' decoder.close();
        },
      }
    );
  }
}
</code></pre>
<p>I won't bore you with the full code, but I've packaged these utilities in the webcodecs-utils package, which can be used as such:</p>
<pre><code class="language-typescript">import {
  SimpleDemuxer,
  VideoDecodeStream,
  VideoEncodeStream,
  SimpleMuxer,
} from "webcodecs-utils";
</code></pre>
<p>Our code for transcoding a file then becomes this:</p>
<pre><code class="language-typescript">const demuxer = new SimpleDemuxer(videoFile);
await demuxer.load();
const decoderConfig = await demuxer.getVideoDecoderConfig();

const encoderConfig = {/*Whatever we decide*/};

// Set up muxer
const muxer = new SimpleMuxer({ video: "avc" });

// Build the upscaling pipeline
await demuxer.videoStream()
  .pipeThrough(new VideoDecodeStream(decoderConfig))
  .pipeThrough(new VideoEncodeStream(encoderConfig))
  .pipeTo(muxer.videoSink());

// Get output
const blob = await muxer.finalize();
</code></pre>
<p>For this intermediate demo, just to actually get transcoding to work, we'll download a <a href="https://katana.video/files/hero-small.mp4">pre-built file</a>, and we'll introduce a toggle to output an mp4 file (using <code>h264)</code> or a webm file (using <code>vp9</code>).</p>
<p>We'll use <code>avc1.4d0034</code> for h264 (most widely supported h264 codec string) and <code>vp09.00.40.08.00</code> for vp9 (most widely supported vp9 string).</p>
<p>Here's a basic transcoding demo on CodePen:</p>
<div class="embed-wrapper"><iframe width="100%" height="350" src="https://codepen.io/Sam-Bhattacharyya/embed/YPGvBgO" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="CodePen embed" scrolling="no" allowtransparency="true" allowfullscreen="true" loading="lazy"></iframe></div>

<h3 id="heading-transformations">Transformations</h3>
<p>If we want to do any kind of transformations to the video, like flips, crops, rotations, resizing, and so on, we can't just work with pure <em>VideoFrame</em> objects.</p>
<p>The simplest way to accomplish this would be to introduce a Canvas element, where we'll use a <a href="https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D">2d Canvas Context</a> to manipulate our source frame and draw that to a canvas.</p>
<pre><code class="language-typescript">const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');

// Very easy to do transformations
ctx.drawImage(sourceFrame, 0, 0);
</code></pre>
<p>We'll then use the Canvas as a source image for our output video frame.</p>
<pre><code class="language-typescript">const outFrame = new VideoFrame(canvas, {timestamp: sourceFrame.timestamp});
</code></pre>
<p>To apply a resize operation, we'll first set the canvas dimensions to our output height and width.</p>
<pre><code class="language-typescript">const canvas = new OffscreenCanvas(outputWidth, outputHeight);
const ctx = canvas.getContext('2d');

// Resize sourceFrame to fit output dimensions
ctx.drawImage(sourceFrame, 0, 0, outputWidth, outputHeight);
</code></pre>
<p>To apply a horizontal flip operation with canvas2d, we can do the following:</p>
<pre><code class="language-typescript">ctx.scale(-1, 1);
ctx.translate(-outputWidth, 0);
ctx.drawImage(sourceFrame, 0, 0, outputWidth, outputHeight);
</code></pre>
<p>You can create a full render function that applies these transformations which looks like this:</p>
<pre><code class="language-typescript">function render(videoFrame, outW, outH, flipped) {

  canvas.width  = outW;
  canvas.height = outH;

  if (flipped) {
    ctx.scale(-1, 1);
    ctx.translate(-outW, 0);
  }
  ctx.drawImage(videoFrame, 0, 0, outW, outH);

}
</code></pre>
<p>Here's an interactive demo of what these transformations look like:</p>
<div class="embed-wrapper"><iframe width="100%" height="350" src="https://codepen.io/Sam-Bhattacharyya/embed/WbGymNQ" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="CodePen embed" scrolling="no" allowtransparency="true" allowfullscreen="true" loading="lazy"></iframe></div>

<h3 id="heading-transform-pipeline">Transform Pipeline</h3>
<p>With these transformations, we need to adjust our pipeline to include a transformation step. It will take in a <em>VideoFrame</em>, apply the transforms, and return a transformed frame.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/0111f887-1f27-4436-a68e-ec91b2dd9959.svg" alt="Transcoding pipeline with transforms" style="display:block;margin:0 auto" width="1344" height="576" loading="lazy">

<p>In the webcodecs-utils package, there is a VideoProcessStream object for this purpose, which takes in an async function which takes in a <em>VideoFrame</em> and returns a <em>VideoFrame:</em></p>
<pre><code class="language-typescript">import { VideoProcessStream} from "webcodecs-utils";
 
new VideoProcessStream(async (frame) =&gt; {
      // Apply transformations
      return procesedFrame;
    }),
</code></pre>
<p>So to apply our transformations, we can set it up as so:</p>
<pre><code class="language-typescript">import { VideoProcessStream} from "webcodecs-utils";
 

const canvas = new OffscreenCanvas(outW, outH);
const ctx = canvas.getContext('2d');

const processStream = new VideoProcessStream(async (frame) =&gt; {
  
  if (flipped) {
    ctx.scale(-1, 1);
    ctx.translate(-outW, 0);
  }
  ctx.drawImage(frame, 0, 0, outW, outH);

  return new VideoFrame(canvas, {timestamp: frame.timestamp});

});
</code></pre>
<p>And then our full pipeline looks like this:</p>
<pre><code class="language-typescript">const demuxer = new SimpleDemuxer(videoFile);
await demuxer.load();
const decoderConfig = await demuxer.getVideoDecoderConfig();

const encoderConfig = {/*Whatever we decide*/};

// Set up muxer
const muxer = new SimpleMuxer({ video: "avc" });

// Build the upscaling pipeline
await demuxer.videoStream()
  .pipeThrough(new VideoDecodeStream(decoderConfig))
  .pipeThrough(processStream) // Just defined this
  .pipeThrough(new VideoEncodeStream(encoderConfig))
  .pipeTo(muxer.videoSink());

// Get output
const blob = await muxer.finalize();
</code></pre>
<p>Here's a full working demo with the process pipeline:</p>
<div class="embed-wrapper"><iframe width="100%" height="350" src="https://codepen.io/Sam-Bhattacharyya/embed/PwGaLPM" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="CodePen embed" scrolling="no" allowtransparency="true" allowfullscreen="true" loading="lazy"></iframe></div>

<h3 id="heading-complete-demo">Complete Demo</h3>
<p>Now, for the complete tool, we'll make some key changes:</p>
<ul>
<li><p>You can upload your own video</p>
</li>
<li><p>We'll preview the transformations by extracting a frame</p>
</li>
<li><p>We'll add progress measurement</p>
</li>
</ul>
<p>For the input, that's trivial:</p>
<pre><code class="language-html">&lt;input type="file" onchange="handler(event)" /&gt;
</code></pre>
<p>For frame previews, we could use WebCodecs to generate a preview, but because the preview doesn't need frame-level accuracy or high performance, it's easier to just use the HTML5 VideoElement to grab a video frame from the source file.</p>
<pre><code class="language-javascript">async function getFirstFrame(file) {
  const video = document.createElement("video");
  video.src = URL.createObjectURL(file);
  video.muted = true;

  await new Promise((resolve) =&gt; video.addEventListener("loadeddata", resolve, { once: true }));
  video.currentTime = 0;
  await new Promise((resolve) =&gt; video.addEventListener("seeked", resolve, { once: true }));

  return new VideoFrame(video, {timestamp: 0});
}
</code></pre>
<p>Finally, we can calculate progress in the process function by using the frame timestamp / the video duration.</p>
<pre><code class="language-typescript">const {duration} = await demuxer.getMediaInfo();


const processStream = new VideoProcessStream(async (frame) =&gt; {
  
  if (flipped) {
    ctx.scale(-1, 1);
    ctx.translate(-outW, 0);
  }
  ctx.drawImage(frame, 0, 0, outW, outH);

   // Frame timestamps are in microseconds, duration in seconds
  const progress = frame.timestamp/(duration*1e6); 

  return new VideoFrame(canvas, {timestamp: frame.timestamp});

});
</code></pre>
<p>Putting this all together, we can finally put together a full working video converter utility:</p>
<div class="embed-wrapper"><iframe width="100%" height="350" src="https://codepen.io/Sam-Bhattacharyya/embed/WbGymaj" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="CodePen embed" scrolling="no" allowtransparency="true" allowfullscreen="true" loading="lazy"></iframe></div>

<p>And that's it! We've built an MVP of something actually useful with WebCodecs 🎉, with Demuxing, Decoding, Canvas Transforms, Encoding, and Muxing.</p>
<p>The only difference between this and a full-fledged browser editing suite like Capcut is the scale and scope of transformations. But the video processing logic would be nearly identical.</p>
<h2 id="heading-production-concerns">Production Concerns</h2>
<p>It's great that we've been able to create something useful, but before we wrap up, it's important to cover some production-level concerns.</p>
<h3 id="heading-codecs">Codecs</h3>
<p>You might have noticed strings like <code>vp09.00.10.08</code> in the demos, but I glossed over the details. We'll cover that now:</p>
<p>First, WebCodecs works with specific codec strings like <code>vp09.00.10.08</code>, not just '<code>vp9</code>'. The following won't work:</p>
<pre><code class="language-plaintext">const codec = VideoEncoder({
    codec: 'vp9', //This won't work!
    //...
})
</code></pre>
<p>As discussed previously, when decoding video, you don't really get a choice of codec. The video is already encoded, and so you need to get the codec from the video, as shown in the previous demos.</p>
<p>The demuxing libraries mentioned will identify the correct codec string, so you don't need to worry about that.</p>
<pre><code class="language-typescript">const decoderConfig = await demuxer.getVideoDecoderConfig();
//decoderConfig.codec = exact codec string for the video
</code></pre>
<p>When encoding a video, you can can choose your codec. Some people care a lot about codec choice, but from a very practical, pragmatic perspective, these rules of thumb should work for most developers:</p>
<ul>
<li><p>If the videos your app generates will be downloaded by users and/or you want to output mp4 files, use <code>h264</code>.</p>
</li>
<li><p>If the videos generated are for internal use or you control video playback, and you don't care about format, use <code>vp9</code> with webm (open source, better compression, most widely supported codec).</p>
</li>
<li><p>For most apps, these two options will cover you — deeper codec selection is a rabbit hole you don't need to go down yet.</p>
</li>
</ul>
<p>Once you have a codec family chosen, you need to choose a specific codec string such as <code>avc1.42001f</code>.</p>
<p>The other numbers in the string specify certain codec parameters which are not as important from a developer perspective. If your goal is maximum compatibility, here's your cheat sheet for what codec strings to use</p>
<h5 id="heading-h264-for-mp4-files"><strong>h264</strong> (for mp4 files)</h5>
<ul>
<li><p><code>avc1.42001f</code> - base profile, most compatible, supports up to 720p (<a href="https://webcodecsfundamentals.org/codecs/avc1.42001f.html">99.6% support</a>)</p>
</li>
<li><p><code>avc1.4d0034</code> - main profile, level 5.2 (supports up to 4K) (<a href="https://webcodecsfundamentals.org/codecs/avc1.4d0034.html">98.9% support</a>)</p>
</li>
<li><p><code>avc1.42003e</code> - base profile, level 6.2 (supports up to 8k) (<a href="https://webcodecsfundamentals.org/codecs/avc1.42003e.html">86.8% support</a>)</p>
</li>
<li><p><code>avc1.64003e</code> - high profile - level 6.2 (supports up to 8k) (<a href="https://webcodecsfundamentals.org/codecs/avc1.64003e.html">85.9% support</a>)</p>
</li>
</ul>
<h5 id="heading-vp9-for-webm-files"><strong>vp9</strong> (for webm files)</h5>
<ul>
<li><p><code>vp09.00.10.08.00</code> - basic, most compatible, level 1 (<a href="https://webcodecsfundamentals.org/codecs/vp09.00.10.08.00.html">99.98% support</a>)</p>
</li>
<li><p><code>vp09.00.40.08.00</code> - level 4 (<a href="https://webcodecsfundamentals.org/codecs/vp09.00.40.08.00.html">99.96% support</a>)</p>
</li>
<li><p><code>vp09.00.50.08.00</code> - level 5 (<a href="https://webcodecsfundamentals.org/codecs/vp09.00.50.08.00.html">99.97% support</a>)</p>
</li>
<li><p><code>vp09.00.61.08.00</code> - level 6 (<a href="https://webcodecsfundamentals.org/codecs/vp09.00.61.08.00.html">99.97% support</a>)</p>
</li>
</ul>
<p>You can also use the <em>getCodecString</em> function from the <a href="https://www.npmjs.com/package/webcodecs-utils">webcodecs-utils</a> package:</p>
<pre><code class="language-typescript">import { getCodecString } from 'webcodecs-utils'

const codec_string = getCodecString('vp9', width, height, bitrate)
</code></pre>
<p>You can find a comprehensive list of what codecs and codec strings you can use in WebCodecs <a href="https://webcodecsfundamentals.org/datasets/codec-support-table/">here</a>.</p>
<h3 id="heading-bit-rate">Bit rate</h3>
<p>On top of height and width (which you presumably know from your content) and a codec string (which we just discussed), you also need to specify a bit rate when encoding video.</p>
<p>Video Compression algorithms have a trade-off between quality and file size. You can have high quality video with big file sizes, or lower quality video with lower file sizes.</p>
<p>Here's a quick visualization of what different quality levels look like for a 1080p video encoded at different bit rates:</p>
<p><strong>300 kbps</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/43d4af76-9951-47f8-9833-c64ea8034ded.png" alt="300kbps frame" style="display:block;margin:0 auto" width="256" height="256" loading="lazy">

<p><strong>1 Mbps</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/2872effb-d8ae-4001-a82a-00338bf69168.png" alt="1Mbps frame" style="display:block;margin:0 auto" width="256" height="256" loading="lazy">

<p><strong>3 Mbps</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/e25723ca-2896-4c85-be91-56fcaf4a426b.png" alt="3 Mbps frame" style="display:block;margin:0 auto" width="256" height="256" loading="lazy">

<p><strong>10 Mbps</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6984c5b1feda93761574fcb1/cdb983e9-d55d-49f5-9852-4e85f053f6ba.png" alt="10 Mbps frame" style="display:block;margin:0 auto" width="256" height="256" loading="lazy">

<p>Here's a quick lookup table for bitrate guidance:</p>
<table>
<thead>
<tr>
<th><strong>Resolution</strong></th>
<th><strong>Bitrate (30fps)</strong></th>
<th><strong>Bitrate (60fps)</strong></th>
</tr>
</thead>
<tbody><tr>
<td>4K</td>
<td>13-20 Mbps</td>
<td>20-30 Mbps</td>
</tr>
<tr>
<td>1080p</td>
<td>4.5-6 Mbps</td>
<td>6-9 Mbps</td>
</tr>
<tr>
<td>720p</td>
<td>2-4 Mbps</td>
<td>3-6 Mbps</td>
</tr>
<tr>
<td>480p</td>
<td>1.5-2 Mbps</td>
<td>2-3 Mbps</td>
</tr>
<tr>
<td>360p</td>
<td>0.5-1 Mbps</td>
<td>1-1.5 Mbps</td>
</tr>
<tr>
<td>240p</td>
<td>300-500 kbps</td>
<td>500-800 kbps</td>
</tr>
</tbody></table>
<p>You can also use this utility function in your own app as a quick approximation:</p>
<pre><code class="language-typescript">function getBitrate(width, height, fps, quality = 'good') {
    const pixels = width * height;

    const qualityFactors = {
      'low': 0.05,
      'good': 0.08,
      'high': 0.10,
      'very-high': 0.15
    };

    const factor = qualityFactors[quality] || qualityFactors['good'];

    // Returns bitrate in bits per second
    return pixels * fps * factor;
  }
</code></pre>
<p>The same function is also available in the webcodecs-utils package:</p>
<pre><code class="language-typescript">import { getBitrate } from 'webcodecs-utils'
</code></pre>
<h3 id="heading-gpu-vs-cpu">GPU vs CPU</h3>
<p>Most user devices have some type of graphics card (typically called integrated graphics). These are specialized chips with specific silicon architectures optimized for encoding and decoding video, as well as for basic graphics.</p>
<p>You might hear "GPU" and think AI data centers and gamers. But as far as web applications are concerned, almost everyone has a GPU.</p>
<p>This is important because while most frontend-development almost exclusively deals with the CPU, WebCodecs and video processing work primarily on the GPU.</p>
<p>Here's a quick guide for what kind of data is stored where:</p>
<table>
<thead>
<tr>
<th><strong>Data Type</strong></th>
<th><strong>Location</strong></th>
</tr>
</thead>
<tbody><tr>
<td>VideoFrame</td>
<td>GPU</td>
</tr>
<tr>
<td>EncodedVideoChunk</td>
<td>CPU</td>
</tr>
<tr>
<td>ImageBitmap</td>
<td>GPU</td>
</tr>
<tr>
<td>ArrayBuffer</td>
<td>CPU</td>
</tr>
<tr>
<td>File</td>
<td>CPU + Disk</td>
</tr>
</tbody></table>
<p>There's a performance cost to moving data around, and this also becomes important for managing memory.</p>
<h3 id="heading-memory">Memory</h3>
<p>VideoFrame objects can be quite large&nbsp;–&nbsp;30MB for a 4K video. A user's graphics card typically reserves some portion of RAM for "Video Memory" or "VRAM" which is where <em>VideoFrame</em> objects would be stored.</p>
<p>So if a user has 8GB of RAM, they would typically have 2GB of VRAM (how much is decided by the operating system).</p>
<p>If the amount of video data exceeds VRAM, your application will crash. This means that for a typical user, if you have more than 67 4K frames in memory (~2 seconds of video) the program will crash.</p>
<h4 id="heading-when-videoframes-are-generated">When VideoFrames are generated</h4>
<p>VideoFrame objects are generated whenever you create a <code>new VideoFrame(source)</code> but also from the <code>VideoDecoder</code>, specifically the output callback. Every time a frame is generated, memory usage goes up.</p>
<h4 id="heading-how-to-remove-videoframes">How to remove VideoFrames</h4>
<p>You can't rely on standard garbage collection for VideoFrame objects. You have to explicitly call close() on a frame when you're done:</p>
<pre><code class="language-typescript">frame.close()
</code></pre>
<p>In the Streams/Pipeline code and demo showed earlier, frames are actually being <a href="https://github.com/sb2702/webcodecs-utils/blob/main/src/streams/video-encode-stream.ts">closed</a> as soon as they are encoded in the <em>VideoProcessStream</em> and <em>VideoEncodeStream</em> interfaces.</p>
<p>The other reason Streams are helpful for WebCodecs is the <code>highWaterMark</code> property, which defaults to 10. What this means is that when you run:</p>
<pre><code class="language-typescript">await demuxer.videoStream()
  .pipeThrough(new VideoDecodeStream(decoderConfig))
  .pipeThrough(processStream) 
  .pipeThrough(new VideoEncodeStream(encoderConfig))
  .pipeTo(muxer.videoSink());
</code></pre>
<p>You ensure that no more than 10 video frames are in memory at any given time. The Streams API allows you to specify that limit while the browser itself deals with the logic of how to make that happen.</p>
<p>If you don't use the Streams API, you'll need to make sure you manage keeping track of memory limits and number of open video frames yourself.</p>
<h2 id="heading-further-resources">Further Resources</h2>
<p>Through this article we've gone over the basics of video processing, introduced the core concepts of the WebCodecs API, and built an MVP of a video converter utility. This is one of the simplest possible demos which actually touches all parts of the API. We also covered some basic production concerns.</p>
<p>This is just an introduction, and only scratches the surface of WebCodecs. For how simple the API looks, building a proper, production-ready WebCodecs application requires moving beyond hello-world demos.</p>
<p>To learn more about WebCodecs, you can check out <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API">MDN</a> and the <a href="https://webcodecsfundamentals.org/">WebCodecsFundamentals</a>, a comprehensive online textbook going much more in depth on WebCodecs.</p>
<p>You can also examine the source code of existing, production tested apps like <a href="https://www.remotion.dev/convert">Remotion Convert</a> (<a href="https://github.com/remotion-dev/remotion/tree/main/packages/convert">source code</a>) which is most similar to the demo app we covered, and <a href="http://free.upscaler.video/">Free AI Video Upscaler</a> (<a href="https://github.com/sb2702/free-ai-video-upscaler">source code</a>, <a href="https://github.com/sb2702/free-ai-video-upscaler/blob/main/src/processors/pipeline-processor.ts">processing pipeline</a>) which is the inspiration for the design patterns presented here and implemented in <a href="https://www.npmjs.com/package/webcodecs-utils">webcodecs-utils</a>.</p>
<p>Finally, while WebCodecs is harder than it looks, you can make your life a lot easier by using a library like <a href="https://mediabunny.dev/">MediaBunny</a>, which simplifies a lot of the details of things like memory management, file I/O, and other details. I use it in my own production WebCodecs applications.</p>
<p>Whether or not you actually build a full, production grade WebCodecs application, you now at least know that it's an option&nbsp;– one that's relatively new, provides better UX with lower server costs, and which is increasingly being adopted by prominent video applications like Capcut and Descript for its benefits.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Full-Stack SaaS App with TanStack Start, Elysia, and Neon ]]>
                </title>
                <description>
                    <![CDATA[ Most full-stack React tutorials stop at "Hello World." They show you how to render a component, maybe fetch some data, and call it a day. But when you sit down to build a real SaaS application, you im ]]>
                </description>
                <link>https://www.freecodecamp.org/news/full-stack-saas-tanstack-start-elysia-neon/</link>
                <guid isPermaLink="false">69ce8f9b0ff860b6defe701d</guid>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Magnus Rødseth ]]>
                </dc:creator>
                <pubDate>Thu, 02 Apr 2026 15:47:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ae3ac13a-e6b4-4498-aa32-ebd8c60c44a2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most full-stack React tutorials stop at "Hello World." They show you how to render a component, maybe fetch some data, and call it a day.</p>
<p>But when you sit down to build a real SaaS application, you immediately hit a wall of unanswered questions. How do you structure your database? Where does authentication live? How do you make API calls type-safe? How do you handle payments without losing webhooks?</p>
<p>This handbook answers all of those questions. You'll build a production-ready SaaS application from scratch using TanStack Start, Elysia, Drizzle ORM, Neon PostgreSQL, Better Auth, Stripe, and Inngest.</p>
<p>By the end, you will have a deployed application with authentication, a type-safe API, database migrations, payment processing, and background jobs.</p>
<p>I chose this stack after building production applications with Next.js, Express, and Prisma. The combination of TanStack Start and Elysia with Eden Treaty gives you something rare: end-to-end type safety from your database schema to your React components, with zero code generation.</p>
<p>Change a column in your database, and TypeScript tells you everywhere that needs updating. That feedback loop changes how you build software.</p>
<p>Here's what you'll learn:</p>
<ul>
<li><p>How to set up a TanStack Start project with Vite and file-based routing</p>
</li>
<li><p>How to configure a PostgreSQL database with Drizzle ORM and Neon</p>
</li>
<li><p>How to build a type-safe API with Elysia embedded in your web app</p>
</li>
<li><p>How to connect your frontend to your API with Eden Treaty</p>
</li>
<li><p>How to add GitHub OAuth authentication with Better Auth</p>
</li>
<li><p>How to build complete features using a repeatable four-layer pattern</p>
</li>
<li><p>How to process payments with Stripe webhooks</p>
</li>
<li><p>How to run reliable background jobs with Inngest</p>
</li>
<li><p>How to deploy everything to Vercel with Neon</p>
</li>
</ul>
<h3 id="heading-why-tanstack-start-instead-of-nextjs">Why TanStack Start Instead of Next.js?</h3>
<p>You might be wondering –&nbsp;why not just use Next.js? It's the default choice for full-stack React, and for good reason. Next.js pioneered server-side rendering, established conventions that shaped the React ecosystem, and has the largest community of any React framework.</p>
<p>But TanStack Start has three advantages that matter for this kind of project.</p>
<h4 id="heading-1-deployment-flexibility">1. Deployment flexibility</h4>
<p>TanStack Start compiles to standard JavaScript that runs anywhere: Node.js, Bun, Deno, Cloudflare Workers, AWS Lambda, or your own server. Next.js is notoriously difficult to self-host outside of Vercel.</p>
<p>If you search "Next.js Azure App Service container" or "Next.js ISR self-hosted," you'll find years of Stack Overflow questions about edge cases that only appear in production.</p>
<h4 id="heading-2-simpler-mental-model">2. Simpler mental model</h4>
<p>Next.js has grown complex: the App Router, React Server Components, Server Actions, partial prerendering, <code>cache()</code>, <code>unstable_cache()</code>, plus various rendering strategies.</p>
<p>TanStack Start uses full-document SSR with full hydration. There's no opaque server/client boundary confusion. The tradeoff is that you don't get RSC's granular streaming, but you gain clarity and predictability.</p>
<h4 id="heading-3-end-to-end-type-safety">3. End-to-end type safety</h4>
<p>Combined with Elysia and Eden Treaty, TanStack Start gives you compile-time type inference from your database to your UI. No code generation steps. No schema files to keep in sync.</p>
<p>TanStack Router itself provides fully type-safe routing with inferred path params, search params, and loader data.</p>
<p>This is a handbook, so it goes deep. Set aside a few hours, open your editor, and let's build something real.</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-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-how-to-configure-the-database-with-drizzle-and-neon">How to Configure the Database with Drizzle and Neon</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-api-with-elysia">How to Build the API with Elysia</a></p>
</li>
<li><p><a href="#heading-how-to-add-type-safe-api-calls-with-eden-treaty">How to Add Type-Safe API Calls with Eden Treaty</a></p>
</li>
<li><p><a href="#heading-how-to-add-authentication-with-better-auth">How to Add Authentication with Better Auth</a></p>
</li>
<li><p><a href="#heading-how-to-build-a-complete-feature-the-four-layer-pattern">How to Build a Complete Feature (The Four-Layer Pattern)</a></p>
</li>
<li><p><a href="#heading-how-to-add-payments-with-stripe">How to Add Payments with Stripe</a></p>
</li>
<li><p><a href="#heading-how-to-add-background-jobs-with-inngest">How to Add Background Jobs with Inngest</a></p>
</li>
<li><p><a href="#heading-how-to-deploy-to-vercel-with-neon">How to Deploy to Vercel with Neon</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have the following installed:</p>
<ul>
<li><p><a href="https://bun.sh"><strong>Bun</strong></a> (v1.2 or later) for package management and running scripts</p>
</li>
<li><p><a href="https://www.docker.com/products/docker-desktop/"><strong>Docker</strong></a> for running PostgreSQL locally</p>
</li>
<li><p><a href="https://git-scm.com/"><strong>Git</strong></a> for version control</p>
</li>
<li><p>Basic knowledge of React and TypeScript</p>
</li>
</ul>
<p>You'll also need free accounts on these services:</p>
<ul>
<li><p><a href="https://neon.tech"><strong>Neon</strong></a> for your production PostgreSQL database</p>
</li>
<li><p><a href="https://vercel.com"><strong>Vercel</strong></a> for deployment</p>
</li>
<li><p><a href="https://github.com"><strong>GitHub</strong></a> for OAuth authentication (you will create an OAuth app)</p>
</li>
<li><p><a href="https://stripe.com"><strong>Stripe</strong></a> for payment processing (test mode is free)</p>
</li>
</ul>
<p>All of these services have generous free tiers. You won't need to pay anything to follow this tutorial.</p>
<p>You should also be comfortable reading TypeScript code. This handbook assumes you understand generics, type inference, and async/await. If you're new to TypeScript, the <a href="https://www.typescriptlang.org/docs/handbook/">official handbook</a> is a solid starting point.</p>
<h2 id="heading-how-to-set-up-the-project">How to Set Up the Project</h2>
<p>Start by creating a new TanStack Start project. TanStack provides a CLI that scaffolds a project with file-based routing, Vite, and server-side rendering out of the box.</p>
<pre><code class="language-bash">bunx @tanstack/cli@latest create my-saas
cd my-saas
bun install
</code></pre>
<p>The CLI will ask you a few questions. Choose React as your framework and accept the defaults for the rest.</p>
<p>You're using Bun as your package manager and runtime. Bun is significantly faster than npm for installing dependencies and running scripts. It also natively supports TypeScript execution, which means you can run <code>.ts</code> files directly without a compilation step.</p>
<p>If you prefer npm or pnpm, the commands are similar, but this tutorial uses Bun throughout.</p>
<h3 id="heading-how-to-understand-the-project-structure">How to Understand the Project Structure</h3>
<p>Before writing any code, let's look at how you'll organize this project. The key architectural decision is putting all library code under <code>src/lib/</code>. Each integration (database, auth, payments, and so on) gets its own directory with a clean public API through an <code>index.ts</code> file.</p>
<p>Here's the structure you'll build toward:</p>
<pre><code class="language-text">my-saas/
├── src/
│   ├── components/          # React components
│   ├── hooks/               # Custom React hooks
│   ├── lib/
│   │   ├── auth/            # Better Auth (server + client)
│   │   ├── db/              # Drizzle ORM + schema
│   │   ├── jobs/            # Inngest background jobs
│   │   └── payments/        # Stripe integration
│   ├── routes/              # TanStack file-based routing
│   ├── server/
│   │   ├── api.ts           # Elysia API definition
│   │   └── routes/          # API route modules
│   └── start.ts             # TanStack Start entry point
├── docker-compose.yml       # Local PostgreSQL + Neon proxy
├── drizzle.config.ts        # Drizzle Kit configuration
├── vite.config.ts           # Vite + TanStack Start config
└── package.json
</code></pre>
<p>Here's how all the pieces connect:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a694d8d4dc9b42434c218f/5bf61d3b-0587-445a-8be1-79f869aa554b.png" alt="Full-stack SaaS architecture diagram showing TanStack Start handling the frontend, connected to an embedded Elysia API server that integrates with Better Auth for authentication, Stripe for payments, and Inngest for background jobs, with Drizzle ORM providing type-safe database access to Neon PostgreSQL" style="display:block;margin:0 auto" width="5504" height="3072" loading="lazy">

<p>TanStack Start handles your frontend. It talks to an Elysia API server embedded in the same project. Elysia connects to three external services: Better Auth for authentication, Stripe for payments, and Inngest for background jobs. Below the API layer, Drizzle ORM provides type-safe database access to Neon PostgreSQL.</p>
<p>You'll build each layer one at a time, starting with the database.</p>
<p>This pattern keeps every integration isolated. When you need to change how authentication works, you go to <code>src/lib/auth/</code>. When you need to modify the database schema, you go to <code>src/lib/db/</code>. Nothing leaks across boundaries.</p>
<h3 id="heading-how-to-configure-vite">How to Configure Vite</h3>
<p>TanStack Start runs on Vite. Your <code>vite.config.ts</code> needs the TanStack Start plugin, the React plugin, and path resolution for the <code>@/</code> import alias:</p>
<pre><code class="language-typescript">// vite.config.ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsConfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  server: {
    port: 3000,
  },
  plugins: [
    tsConfigPaths({
      projects: ["./tsconfig.json"],
    }),
    tanstackStart(),
    viteReact(),
  ],
});
</code></pre>
<p>The <code>tsConfigPaths</code> plugin reads the <code>paths</code> setting from your <code>tsconfig.json</code>, so you can use <code>@/lib/db</code> instead of <code>../../lib/db</code> throughout your code.</p>
<p>Add this to your <code>tsconfig.json</code>:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
</code></pre>
<h3 id="heading-how-to-install-dependencies">How to Install Dependencies</h3>
<p>Install the core dependencies you'll need throughout this tutorial:</p>
<pre><code class="language-bash"># Framework and routing
bun add @tanstack/react-router @tanstack/react-start react react-dom

# API layer
bun add elysia @elysiajs/eden

# Database
bun add drizzle-orm @neondatabase/serverless ws
bun add -d drizzle-kit

# Authentication
bun add better-auth

# Payments
bun add stripe

# Background jobs
bun add inngest

# Build tools
bun add -d @vitejs/plugin-react vite vite-tsconfig-paths typescript
</code></pre>
<p>Now you have a working TanStack Start project with all the dependencies you'll need. Start the dev server to make sure everything works:</p>
<pre><code class="language-bash">bun run dev
</code></pre>
<p>Visit <code>http://localhost:3000</code> and you should see your app running.</p>
<h2 id="heading-how-to-configure-the-database-with-drizzle-and-neon">How to Configure the Database with Drizzle and Neon</h2>
<p>Every SaaS needs a database. You'll use Drizzle ORM with Neon PostgreSQL. Drizzle gives you type-safe database queries that look like SQL, and Neon gives you a serverless PostgreSQL database that scales to zero when you aren't using it.</p>
<h3 id="heading-why-drizzle-instead-of-prisma">Why Drizzle Instead of Prisma?</h3>
<p>If you have used an ORM in the TypeScript ecosystem before, it was probably Prisma. Prisma is excellent for many use cases, but it has a key limitation for this architecture: it uses code generation.</p>
<p>You write a <code>.prisma</code> schema file, run <code>prisma generate</code>, and Prisma generates a TypeScript client. That generation step adds friction to your development loop and creates artifacts you need to keep in sync.</p>
<p>Drizzle takes a different approach. Your schema is TypeScript. Your queries are TypeScript. Types are inferred at compile time without any generation step.</p>
<p>When you add a column to a table, the types update immediately. This fits perfectly with the rest of the stack, where types flow from Drizzle through Elysia to Eden Treaty without any intermediate steps.</p>
<p>Drizzle also produces SQL that looks like SQL. If you know PostgreSQL, you can read Drizzle queries. There is no Prisma-specific query language to learn.</p>
<h3 id="heading-how-to-set-up-local-postgresql-with-docker">How to Set Up Local PostgreSQL with Docker</h3>
<p>For local development, you'll run PostgreSQL in Docker with a Neon-compatible proxy. This lets you use the same Neon serverless driver locally that you'll use in production.</p>
<p>Create a <code>docker-compose.yml</code> at the project root:</p>
<pre><code class="language-yaml"># docker-compose.yml
services:
  postgres:
    image: postgres:17
    container_name: my-saas-postgres
    restart: unless-stopped
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: my_saas
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  neon-proxy:
    image: ghcr.io/timowilhelm/local-neon-http-proxy:main
    container_name: my-saas-neon-proxy
    restart: unless-stopped
    environment:
      - PG_CONNECTION_STRING=postgres://postgres:postgres@postgres:5432/my_saas
    ports:
      - "4444:4444"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
</code></pre>
<p>The <code>neon-proxy</code> container is the important part. It translates HTTP requests into PostgreSQL wire protocol, which means your Neon serverless driver works locally without any code changes.</p>
<p>In production, Neon handles this translation on their infrastructure. Locally, you need this proxy to bridge the gap between the HTTP-based Neon driver and your plain PostgreSQL container.</p>
<p>The <code>healthcheck</code> on the PostgreSQL container ensures the proxy only starts after the database is ready. Without this, the proxy would try to connect to a database that's still initializing, causing connection errors on first startup.</p>
<p>Start the containers:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<h3 id="heading-how-to-define-your-schema">How to Define Your Schema</h3>
<p>Create the database client and schema. Start with <code>src/lib/db/index.ts</code> for the connection:</p>
<pre><code class="language-typescript">// src/lib/db/index.ts
import { neon, neonConfig } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import ws from "ws";

import * as schema from "./schema";

const isProduction = process.env.NODE_ENV === "production";
const LOCAL_DB_HOST = "db.localtest.me";

let connectionString = process.env.DATABASE_URL;

if (!connectionString) {
  throw new Error("DATABASE_URL environment variable is not set");
}

neonConfig.webSocketConstructor = ws;

if (!isProduction) {
  connectionString = `postgres://postgres:postgres@${LOCAL_DB_HOST}:5432/my_saas`;
  neonConfig.fetchEndpoint = (host) =&gt; {
    const [protocol, port] =
      host === LOCAL_DB_HOST ? ["http", 4444] : ["https", 443];
    return `\({protocol}://\){host}:${port}/sql`;
  };
  neonConfig.useSecureWebSocket = false;
  neonConfig.wsProxy = (host) =&gt;
    host === LOCAL_DB_HOST ? `\({host}:4444/v2` : `\){host}/v2`;
}

const client = neon(connectionString);
export const db = drizzle({ client, schema });

export * from "./schema";
</code></pre>
<p>The <code>db.localtest.me</code> hostname resolves to <code>127.0.0.1</code> and is the standard way to work with the local Neon proxy. In production, the Neon driver connects directly to your Neon database using the <code>DATABASE_URL</code> environment variable.</p>
<p>Now define your schema in <code>src/lib/db/schema.ts</code>. For a SaaS application, you need users, sessions, accounts (for OAuth), and a table for your core business entity. Here's a real production schema:</p>
<pre><code class="language-typescript">// src/lib/db/schema.ts
import {
  boolean,
  integer,
  pgEnum,
  pgTable,
  text,
  timestamp,
  varchar,
} from "drizzle-orm/pg-core";

export const purchaseTierEnum = pgEnum("purchase_tier", ["pro"]);
export const purchaseStatusEnum = pgEnum("purchase_status", [
  "completed",
  "partially_refunded",
  "refunded",
]);

export const users = pgTable("users", {
  id: text("id").primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  name: text("name"),
  image: text("image"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const sessions = pgTable("sessions", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() =&gt; users.id, { onDelete: "cascade" }),
  token: text("token").notNull().unique(),
  expiresAt: timestamp("expires_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const accounts = pgTable("accounts", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() =&gt; users.id, { onDelete: "cascade" }),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  accessTokenExpiresAt: timestamp("access_token_expires_at"),
  refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
  scope: text("scope"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const verifications = pgTable("verifications", {
  id: text("id").primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() =&gt; crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() =&gt; users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment_intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

// Type exports for use in your application
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Purchase = typeof purchases.$inferSelect;
export type NewPurchase = typeof purchases.$inferInsert;
</code></pre>
<p>Push the schema to create the tables:</p>
<pre><code class="language-bash">bun run db:push
</code></pre>
<p>A few things to notice about this schema:</p>
<ol>
<li><p>The <code>users</code>, <code>sessions</code>, <code>accounts</code>, and <code>verifications</code> tables are required by Better Auth. You'll configure the auth library to use these tables in the next section.</p>
</li>
<li><p>The <code>purchases</code> table is your core business entity. It tracks Stripe checkout sessions and links them to users.</p>
</li>
<li><p>Type exports like <code>User</code> and <code>Purchase</code> give you inferred TypeScript types from your schema. You never define types manually. They come from the schema definition.</p>
</li>
<li><p>The <code>$defaultFn</code> on the <code>purchases.id</code> column generates a UUID automatically when you insert a row. The auth tables use text IDs because Better Auth generates its own IDs.</p>
</li>
</ol>
<h3 id="heading-how-to-configure-drizzle-kit">How to Configure Drizzle Kit</h3>
<p>Create <code>drizzle.config.ts</code> at the project root:</p>
<pre><code class="language-typescript">// drizzle.config.ts
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  dialect: "postgresql",
  schema: "./src/lib/db/schema.ts",
  out: "./drizzle",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
  verbose: true,
  strict: true,
});
</code></pre>
<p>Add these scripts to your <code>package.json</code>:</p>
<pre><code class="language-json">{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:push": "drizzle-kit push",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}
</code></pre>
<p>Now push your schema to the local database:</p>
<pre><code class="language-bash">bun run db:push
</code></pre>
<p>Drizzle Kit reads your schema file, compares it to the database, and applies any changes. For development, <code>db:push</code> is fast and convenient. For production, you'll use <code>db:generate</code> and <code>db:migrate</code> to create versioned SQL migration files.</p>
<p>You can open Drizzle Studio to inspect your database visually:</p>
<pre><code class="language-bash">bun run db:studio
</code></pre>
<p>This opens a web UI at <code>https://local.drizzle.studio</code> where you can browse tables, run queries, and inspect data.</p>
<h2 id="heading-how-to-build-the-api-with-elysia">How to Build the API with Elysia</h2>
<p>Here's where this stack gets interesting. Instead of running a separate API server, you embed Elysia directly inside TanStack Start. Both your web app and your API live in the same process, share the same types, and deploy as a single unit.</p>
<h3 id="heading-why-elysia-instead-of-express">Why Elysia Instead of Express?</h3>
<p>If you've built Node.js APIs before, you've probably used Express. It is 15 years old and has a massive ecosystem. But Express was designed before TypeScript, before async/await, and before developers expected type safety across the full stack.</p>
<p>Elysia takes a different approach. It was built for TypeScript from day one. Request bodies, response types, and path parameters are all inferred at compile time.</p>
<p>Combined with Eden Treaty (which you'll set up in the next section), your frontend gets full type safety when calling your API. No code generation. No OpenAPI schemas to keep in sync. Just TypeScript inference.</p>
<p>Elysia also includes built-in request validation using its <code>t</code> (TypeBox) schema builder:</p>
<pre><code class="language-typescript">import { Elysia, t } from "elysia";

new Elysia().post(
  "/users",
  ({ body }) =&gt; {
    // body is typed as { name: string, email: string }
    return createUser(body);
  },
  {
    body: t.Object({
      name: t.String(),
      email: t.String(),
    }),
  }
);
</code></pre>
<p>The schema validates at runtime and provides TypeScript types at compile time. One definition serves both purposes.</p>
<h3 id="heading-how-to-define-your-api">How to Define Your API</h3>
<p>Create <code>src/server/api.ts</code>. This is where all your API routes live:</p>
<pre><code class="language-typescript">// src/server/api.ts
import { Elysia, t } from "elysia";
import { eq } from "drizzle-orm";

import { auth } from "@/lib/auth";
import { db, purchases, users } from "@/lib/db";

export const api = new Elysia({ prefix: "/api" })
  .onRequest(({ request }) =&gt; {
    console.log(`[API] \({request.method} \){request.url}`);
  })
  .onError(({ code, error, path }) =&gt; {
    console.error(`[API ERROR] \({code} on \){path}:`, error);
  })
  .get("/health", () =&gt; ({
    status: "ok",
    timestamp: new Date().toISOString(),
  }))
  .get("/me", async ({ request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    return { user: session.user };
  })
  .get("/payments/status", async ({ request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    const purchase = await db
      .select()
      .from(purchases)
      .where(eq(purchases.userId, session.user.id))
      .limit(1);

    return {
      userId: session.user.id,
      purchase: purchase[0] ?? null,
    };
  });

export type Api = typeof api;
</code></pre>
<p>That last line is critical. <code>export type Api = typeof api</code> exports the full type signature of your API. Eden Treaty uses this type to generate a fully typed client on the frontend.</p>
<p>You'll see how that works shortly.</p>
<p>Notice the pattern for authenticated endpoints: call <code>auth.api.getSession()</code> with the request headers, check if the session exists, and return a 401 if it does not. This is straightforward and explicit. No decorators, no middleware magic.</p>
<p>The <code>onRequest</code> and <code>onError</code> hooks provide logging for every request. In production, you would replace these with structured logging to your observability platform.</p>
<h3 id="heading-how-to-mount-elysia-in-tanstack-start">How to Mount Elysia in TanStack Start</h3>
<p>TanStack Start uses file-based routing. To handle all API requests with Elysia, create a catch-all route at <code>src/routes/api.$.ts</code>:</p>
<pre><code class="language-typescript">// src/routes/api.$.ts
import { createFileRoute } from "@tanstack/react-router";

import { api } from "../server/api";

const handler = ({ request }: { request: Request }) =&gt; api.fetch(request);

export const Route = createFileRoute("/api/$")({
  server: {
    handlers: {
      GET: handler,
      POST: handler,
      PUT: handler,
      PATCH: handler,
      DELETE: handler,
      OPTIONS: handler,
    },
  },
});
</code></pre>
<p>The <code>$</code> in the filename is TanStack Router's wildcard syntax. This route matches any path starting with <code>/api/</code>, and the <code>server.handlers</code> object maps HTTP methods to your Elysia handler. Every request to <code>/api/*</code> gets forwarded to Elysia's <code>fetch</code> method.</p>
<p>This is the key architectural insight: Elysia is embedded inside TanStack Start. There is no separate API server. Your web app and API share the same process, the same port, and the same deployment.</p>
<p>This eliminates CORS issues, simplifies deployment, and means your API types are directly importable on the frontend.</p>
<p>Test your API by visiting <code>http://localhost:3000/api/health</code>. You should see:</p>
<pre><code class="language-json">{ "status": "ok", "timestamp": "2026-03-28T12:00:00.000Z" }
</code></pre>
<h2 id="heading-how-to-add-type-safe-api-calls-with-eden-treaty">How to Add Type-Safe API Calls with Eden Treaty</h2>
<p><a href="https://elysiajs.com/eden/treaty/overview">Eden Treaty</a> is Elysia's companion client library. It's an end-to-end type-safe HTTP client that mirrors your Elysia API's route structure as a JavaScript object. Instead of writing <code>fetch("/api/users")</code> and manually typing the response, you call <code>api.api.users.get()</code> and get full autocompletion, parameter validation, and return type inference, all derived from your server code at compile time with zero code generation.</p>
<p>This is what makes the stack special. Eden Treaty reads the type exported from your Elysia API and generates a fully typed client. Every endpoint, every parameter, every response shape is inferred at compile time.</p>
<h3 id="heading-how-to-set-up-the-treaty-client">How to Set Up the Treaty Client</h3>
<p>Since Elysia is embedded in your TanStack Start app (same origin), you don't need to pass a URL to the Treaty client. You can create the client directly from the Elysia app instance for server-side usage and use a URL-based client for browser-side usage.</p>
<p>The simplest approach is to create a helper function that returns a treaty client:</p>
<pre><code class="language-typescript">// src/lib/treaty.ts
import { treaty } from "@elysiajs/eden";

import type { Api } from "@/server/api";

// For client-side usage, connect to the same origin
export const api = treaty&lt;Api&gt;(
  typeof window !== "undefined"
    ? window.location.origin
    : (process.env.BETTER_AUTH_URL ?? "http://localhost:3000")
);
</code></pre>
<p>Now you can use <code>api</code> anywhere in your application with full type safety:</p>
<pre><code class="language-typescript">// Calling GET /api/health
const { data } = await api.api.health.get();
// data is typed as { status: string, timestamp: string }

// Calling GET /api/me (authenticated)
const { data: me, error } = await api.api.me.get();
// data is typed as { user: { id: string, email: string, ... } }
// error is typed as { error: string } | null
</code></pre>
<p>Notice how the method chain mirrors your route structure. The <code>/api/health</code> endpoint becomes <code>api.api.health.get()</code>. Path segments become properties, and the HTTP method becomes the final function call.</p>
<p>This is all inferred from the <code>type Api = typeof api</code> export.</p>
<h3 id="heading-how-types-flow-from-server-to-client">How Types Flow from Server to Client</h3>
<p>Here's the full picture of how types flow through the stack:</p>
<pre><code class="language-text">┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  Drizzle Schema  │     │    Elysia API    │     │   Eden Treaty   │
│  (schema.ts)     │────▶│   (api.ts)       │────▶│   (client)      │
│                  │     │                  │     │                  │
│  type User =     │     │  .get("/me",     │     │  api.api.me     │
│  typeof users    │     │    () =&gt; user)   │     │    .get()       │
│  .$inferSelect   │     │                  │     │    → { user }   │
└─────────────────┘     └─────────────────┘     └─────────────────┘
</code></pre>
<p>First, <strong>Drizzle</strong> infers TypeScript types from your table definitions. The <code>User</code> type comes from the <code>users</code> table schema.</p>
<p>Then <strong>Elysia</strong> uses those types in route handlers. When a handler returns <code>{ user: session.user }</code>, Elysia captures the return type.</p>
<p>Finally, <strong>Eden Treaty</strong> reads the <code>type Api = typeof api</code> export and generates a client where every endpoint is fully typed.</p>
<p>If you add a field to your <code>users</code> table schema, Drizzle's inferred types update. If your Elysia handler returns that new field, Eden Treaty's client types update. If your React component accesses a field that no longer exists, TypeScript catches the error at compile time.</p>
<p>Zero code generation. Zero runtime overhead. Just TypeScript inference doing what it does best.</p>
<h3 id="heading-how-to-handle-errors-with-eden-treaty">How to Handle Errors with Eden Treaty</h3>
<p>Every Eden Treaty call returns a <code>{ data, error }</code> tuple. This isn't a thrown exception. It's a discriminated union that forces you to handle both success and failure cases:</p>
<pre><code class="language-typescript">const { data, error } = await api.api.me.get();

if (error) {
  // error is typed based on what your Elysia handler can return
  console.error("Failed to fetch user:", error);
  return null;
}

// data is now narrowed to the success type
console.log(data.user.email);
</code></pre>
<p>This pattern eliminates the "forgot to handle the error" class of bugs that are common with <code>fetch</code> or Axios, where errors are thrown and easily missed. With Eden Treaty, the TypeScript compiler reminds you.</p>
<h3 id="heading-how-to-use-eden-treaty-in-route-loaders">How to Use Eden Treaty in Route Loaders</h3>
<p>TanStack Start routes have <code>loader</code> functions that run on the server during SSR and on the client during navigation. You can use Eden Treaty in these loaders to fetch data before the page renders:</p>
<pre><code class="language-typescript">// src/routes/_authenticated/dashboard.tsx
import { createFileRoute } from "@tanstack/react-router";

import { api } from "@/lib/treaty";

export const Route = createFileRoute("/_authenticated/dashboard")({
  loader: async () =&gt; {
    const { data } = await api.api.payments.status.get();
    return { purchase: data?.purchase ?? null };
  },
  component: DashboardPage,
});

function DashboardPage() {
  const { purchase } = Route.useLoaderData();

  return (
    &lt;div&gt;
      &lt;h1&gt;Dashboard&lt;/h1&gt;
      {purchase ? (
        &lt;p&gt;Your plan: {purchase.tier}&lt;/p&gt;
      ) : (
        &lt;p&gt;No active plan.&lt;/p&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p>The <code>loader</code> runs before the component renders, so the page never shows a loading spinner for its initial data. <code>Route.useLoaderData()</code> returns fully typed data based on what the loader returns. Change the loader's return type, and TypeScript catches mismatches in the component.</p>
<h2 id="heading-how-to-add-authentication-with-better-auth">How to Add Authentication with Better Auth</h2>
<p>Every SaaS needs authentication. In this tutorial, you'll use Better Auth with GitHub OAuth. Better Auth is a framework-agnostic auth library that works natively with Drizzle and has first-class support for TanStack Start.</p>
<h3 id="heading-how-to-create-a-github-oauth-app">How to Create a GitHub OAuth App</h3>
<p>Before writing any code, create a GitHub OAuth application:</p>
<ol>
<li><p>Go to <a href="https://github.com/settings/developers">GitHub Developer Settings</a></p>
</li>
<li><p>Click "New OAuth App"</p>
</li>
<li><p>Set the Homepage URL to <code>http://localhost:3000</code></p>
</li>
<li><p>Set the Authorization callback URL to <code>http://localhost:3000/api/auth/callback/github</code></p>
</li>
<li><p>Click "Register application"</p>
</li>
<li><p>Copy the Client ID and generate a Client Secret</p>
</li>
</ol>
<p>Add these to a <code>.env</code> file at the project root:</p>
<pre><code class="language-bash"># .env
DATABASE_URL=postgres://postgres:postgres@db.localtest.me:5432/my_saas
BETTER_AUTH_SECRET=your-random-32-character-string-here
BETTER_AUTH_URL=http://localhost:3000
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
</code></pre>
<p>Generate a random secret for <code>BETTER_AUTH_SECRET</code>:</p>
<pre><code class="language-bash">openssl rand -base64 32
</code></pre>
<h3 id="heading-how-to-configure-the-auth-server">How to Configure the Auth Server</h3>
<p>Create <code>src/lib/auth/index.ts</code>. This is the server-side auth configuration:</p>
<pre><code class="language-typescript">// src/lib/auth/index.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";

import * as schema from "@/lib/db";
import { db } from "@/lib/db";

const isDev = process.env.NODE_ENV !== "production";
const baseURL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";

export const auth = betterAuth({
  baseURL,
  database: drizzleAdapter(db, {
    provider: "pg",
    usePlural: true,
    schema: {
      users: schema.users,
      sessions: schema.sessions,
      accounts: schema.accounts,
      verifications: schema.verifications,
    },
  }),

  socialProviders: {
    github: {
      clientId: process.env.GITHUB_CLIENT_ID ?? "",
      clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
    },
  },

  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 days
    updateAge: 60 * 60 * 24,      // refresh daily
    cookieCache: {
      enabled: true,
      maxAge: 5 * 60, // 5 minutes
    },
  },

  trustedOrigins: isDev
    ? ["http://localhost:3000"]
    : [baseURL],

  plugins: [tanstackStartCookies()],
});

export type Auth = typeof auth;
export type Session = typeof auth.$Infer.Session;
</code></pre>
<p>Key details in this configuration:</p>
<ul>
<li><p><code>drizzleAdapter</code> connects Better Auth to your Drizzle database. The <code>usePlural: true</code> option tells it your tables are named <code>users</code> (not <code>user</code>), <code>sessions</code> (not <code>session</code>), and so on.</p>
</li>
<li><p><code>tanstackStartCookies()</code> is a plugin that handles cookie management for TanStack Start's SSR. Without this, sessions won't persist correctly during server-side rendering.</p>
</li>
<li><p><code>cookieCache</code> stores session data in the cookie for 5 minutes, reducing database lookups on every request.</p>
</li>
</ul>
<h3 id="heading-how-to-configure-the-auth-client">How to Configure the Auth Client</h3>
<p>Create <code>src/lib/auth/client.ts</code> for the browser-side auth client:</p>
<pre><code class="language-typescript">// src/lib/auth/client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: "",
});

export const { signIn, signOut, useSession } = authClient;
</code></pre>
<p>The <code>baseURL</code> is an empty string because Elysia is embedded in your TanStack Start app. Auth requests go to <code>/api/auth/*</code> on the same origin. No separate auth server needed.</p>
<h3 id="heading-how-to-mount-auth-routes">How to Mount Auth Routes</h3>
<p>Better Auth needs to handle requests at <code>/api/auth/*</code>. Since Elysia handles all <code>/api/*</code> routes, you mount Better Auth's handler inside Elysia.</p>
<p>Add this to your <code>src/server/api.ts</code>:</p>
<pre><code class="language-typescript">// In src/server/api.ts, add Better Auth's handler
export const api = new Elysia({ prefix: "/api" })
  // Mount Better Auth to handle /api/auth/* routes
  .mount(auth.handler)
  // ... rest of your routes
</code></pre>
<p>The <code>.mount(auth.handler)</code> call tells Elysia to forward any request matching Better Auth's routes to the auth handler. This covers login, logout, session management, and OAuth callbacks.</p>
<h3 id="heading-how-to-protect-routes">How to Protect Routes</h3>
<p>TanStack Start uses layout routes to protect groups of pages. Create <code>src/routes/_authenticated.tsx</code>:</p>
<pre><code class="language-typescript">// src/routes/_authenticated.tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";

import { auth } from "@/lib/auth";

const getCurrentUser = createServerFn().handler(async () =&gt; {
  const rawHeaders = getRequestHeaders();
  const headers = new Headers(rawHeaders as HeadersInit);
  const session = await auth.api.getSession({ headers });
  return session?.user ?? null;
});

export const Route = createFileRoute("/_authenticated")({
  beforeLoad: async ({ location }) =&gt; {
    const user = await getCurrentUser();

    if (!user) {
      throw redirect({
        to: "/login",
        search: { redirect: location.pathname },
      });
    }

    return { user };
  },
  component: AuthenticatedLayout,
});

function AuthenticatedLayout() {
  return &lt;Outlet /&gt;;
}
</code></pre>
<p>The <code>_authenticated</code> prefix (with underscore) makes this a layout route in TanStack Router. Any route nested inside <code>src/routes/_authenticated/</code> will run the <code>beforeLoad</code> check first. If the user isn't logged in, they get redirected to <code>/login</code> with a redirect parameter so they return to the original page after signing in.</p>
<p>The <code>createServerFn</code> runs on the server during SSR. It reads the request cookies, checks for a valid session, and returns the user. This means your auth check happens server-side before any HTML is sent to the browser.</p>
<p>Now any file you create under <code>src/routes/_authenticated/</code> is automatically protected. For example, <code>src/routes/_authenticated/dashboard.tsx</code> requires authentication.</p>
<h3 id="heading-how-to-build-the-login-page">How to Build the Login Page</h3>
<p>Create a login page at <code>src/routes/login.tsx</code>:</p>
<pre><code class="language-typescript">// src/routes/login.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod";

import { signIn } from "@/lib/auth/client";

const searchSchema = z.object({
  redirect: z.string().optional(),
});

export const Route = createFileRoute("/login")({
  validateSearch: searchSchema,
  component: LoginPage,
});

function LoginPage() {
  const { redirect: redirectTo } = Route.useSearch();
  const [isLoading, setIsLoading] = useState(false);

  const handleGitHubLogin = async () =&gt; {
    setIsLoading(true);
    const callbackURL = redirectTo
      ? `\({window.location.origin}\){redirectTo}`
      : `${window.location.origin}/dashboard`;

    await signIn.social({
      provider: "github",
      callbackURL,
    });
  };

  return (
    &lt;div className="flex min-h-screen items-center justify-center"&gt;
      &lt;div className="w-full max-w-md rounded-lg border p-8"&gt;
        &lt;h1 className="mb-6 text-2xl font-bold"&gt;Sign In&lt;/h1&gt;
        &lt;button
          onClick={handleGitHubLogin}
          disabled={isLoading}
          className="w-full rounded-md bg-gray-900 px-4 py-3 text-white"
        &gt;
          {isLoading ? "Signing in..." : "Sign in with GitHub"}
        &lt;/button&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>TanStack Router's <code>validateSearch</code> validates query parameters with Zod. The <code>redirect</code> parameter is typed as an optional string, and <code>Route.useSearch()</code> returns a type-safe object. No manual parsing needed.</p>
<h3 id="heading-how-to-add-login-redirect-middleware">How to Add Login Redirect Middleware</h3>
<p>You also want to redirect authenticated users away from the login page. Create the entry point at <code>src/start.ts</code>:</p>
<pre><code class="language-typescript">// src/start.ts
import { redirect } from "@tanstack/react-router";
import { createMiddleware, createStart } from "@tanstack/react-start";
import { getRequestHeaders, getRequestUrl } from "@tanstack/react-start/server";

import { auth } from "@/lib/auth";

const authMiddleware = createMiddleware({ type: "request" }).server(
  async ({ next }) =&gt; {
    const rawHeaders = getRequestHeaders();
    const headers = new Headers(rawHeaders as HeadersInit);
    const url = getRequestUrl();

    if (url.pathname !== "/login") {
      return next();
    }

    const session = await auth.api.getSession({ headers });

    if (session?.user) {
      const redirectTo = url.searchParams.get("redirect");
      throw redirect({
        to: redirectTo || "/dashboard",
      });
    }

    return next();
  }
);

export const startInstance = createStart(() =&gt; ({
  requestMiddleware: [authMiddleware],
}));
</code></pre>
<p>This middleware runs on every request. If the user is already authenticated and visits <code>/login</code>, they get redirected to the dashboard (or to whatever page they originally wanted to reach).</p>
<h2 id="heading-how-to-build-a-complete-feature-the-four-layer-pattern">How to Build a Complete Feature (The Four-Layer Pattern)</h2>
<p>Now that you have a database, API, type-safe client, and authentication, it's time to build a real feature. Every feature in this architecture follows the same four-layer pattern:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69a694d8d4dc9b42434c218f/2e658c33-30fa-49ea-b5fc-50428d336cc4.png" alt="The four-layer feature pattern used throughout the tutorial: Layer 1 Schema defines the data structure, Layer 2 API exposes CRUD operations, Layer 3 Hooks connects React to the API, and Layer 4 UI renders and handles user interactions" style="display:block;margin:0 auto" width="5504" height="3072" loading="lazy">

<p>Once you understand this pattern, adding features becomes mechanical. Let's walk through building a complete purchase status feature that lets authenticated users check their purchase history.</p>
<h3 id="heading-layer-1-schema">Layer 1: Schema</h3>
<p>You already defined the <code>purchases</code> table in your schema earlier. For reference:</p>
<pre><code class="language-typescript">// src/lib/db/schema.ts
export const purchases = pgTable("purchases", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() =&gt; crypto.randomUUID()),
  userId: text("user_id")
    .notNull()
    .references(() =&gt; users.id, { onDelete: "cascade" }),
  stripeCheckoutSessionId: text("stripe_checkout_session_id")
    .notNull()
    .unique(),
  stripeCustomerId: text("stripe_customer_id"),
  stripePaymentIntentId: text("stripe_payment_intent_id"),
  tier: purchaseTierEnum("tier").notNull(),
  status: purchaseStatusEnum("status").notNull().default("completed"),
  amount: integer("amount").notNull(),
  currency: text("currency").notNull().default("usd"),
  purchasedAt: timestamp("purchased_at").notNull().defaultNow(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
</code></pre>
<p>If you're adding a new feature, this is where you start. Define the table, run <code>bun run db:push</code>, and move to Layer 2.</p>
<h3 id="heading-layer-2-api">Layer 2: API</h3>
<p>Create an API route module at <code>src/server/routes/purchases.ts</code>:</p>
<pre><code class="language-typescript">// src/server/routes/purchases.ts
import { eq } from "drizzle-orm";
import { Elysia } from "elysia";

import { auth } from "@/lib/auth";
import { db, purchases } from "@/lib/db";

export const purchasesRoute = new Elysia({ prefix: "/purchases" })
  .get("/status", async ({ request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session?.user) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    const purchase = await db
      .select()
      .from(purchases)
      .where(eq(purchases.userId, session.user.id))
      .limit(1);

    return purchase[0] ?? null;
  });
</code></pre>
<p>Then register this route module in your main API file:</p>
<pre><code class="language-typescript">// src/server/api.ts
import { purchasesRoute } from "./routes/purchases";

export const api = new Elysia({ prefix: "/api" })
  .mount(auth.handler)
  .use(purchasesRoute)
  // ... other routes
</code></pre>
<p>The <code>.use()</code> method composes Elysia instances. Each route module is an independent Elysia instance with its own prefix, and <code>use</code> merges them into the main app. Eden Treaty sees the full composed type, so your client automatically knows about the new endpoints.</p>
<h3 id="heading-layer-3-hooks">Layer 3: Hooks</h3>
<p>Create a custom hook that connects your React components to the API:</p>
<pre><code class="language-typescript">// src/hooks/use-purchase-status.ts
import { useQuery } from "@tanstack/react-query";

import { api } from "@/lib/treaty";

export function usePurchaseStatus() {
  return useQuery({
    queryKey: ["purchase-status"],
    queryFn: async () =&gt; {
      const { data, error } = await api.api.purchases.status.get();
      if (error) throw new Error("Failed to fetch purchase status");
      return data;
    },
  });
}
</code></pre>
<p>TanStack Query handles caching, refetching, loading states, and error states. The <code>queryKey</code> identifies this data in the cache. If multiple components call <code>usePurchaseStatus()</code>, only one network request is made.</p>
<p>For mutations (creating, updating, or deleting data), use <code>useMutation</code>:</p>
<pre><code class="language-typescript">// src/hooks/use-checkout.ts
import { useMutation } from "@tanstack/react-query";

import { api } from "@/lib/treaty";

export function useCheckout() {
  return useMutation({
    mutationFn: async () =&gt; {
      const { data, error } = await api.api.payments.checkout.post();
      if (error) throw new Error("Failed to create checkout session");
      return data;
    },
    onSuccess: (data) =&gt; {
      // Redirect to Stripe Checkout
      if (data?.url) {
        window.location.href = data.url;
      }
    },
  });
}
</code></pre>
<h3 id="heading-layer-4-ui">Layer 4: UI</h3>
<p>Use the hooks in your React components:</p>
<pre><code class="language-tsx">// src/components/purchase-status.tsx
import { usePurchaseStatus } from "@/hooks/use-purchase-status";

export function PurchaseStatus() {
  const { data: purchase, isLoading, error } = usePurchaseStatus();

  if (isLoading) {
    return &lt;div&gt;Loading...&lt;/div&gt;;
  }

  if (error) {
    return &lt;div&gt;Failed to load purchase status.&lt;/div&gt;;
  }

  if (!purchase) {
    return (
      &lt;div className="rounded-lg border p-6"&gt;
        &lt;h2 className="text-lg font-semibold"&gt;No Active Purchase&lt;/h2&gt;
        &lt;p className="mt-2 text-gray-600"&gt;
          You have not purchased a plan yet.
        &lt;/p&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;div className="rounded-lg border p-6"&gt;
      &lt;h2 className="text-lg font-semibold"&gt;
        {purchase.tier.charAt(0).toUpperCase() + purchase.tier.slice(1)} Plan
      &lt;/h2&gt;
      &lt;p className="mt-2 text-gray-600"&gt;
        Status: {purchase.status}
      &lt;/p&gt;
      &lt;p className="text-sm text-gray-500"&gt;
        Purchased on{" "}
        {new Date(purchase.purchasedAt).toLocaleDateString()}
      &lt;/p&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>That's the complete four-layer pattern. The schema defines the data. The API exposes it. Hooks connect React to the API. The UI renders the result. Every feature you add follows these same four steps.</p>
<h3 id="heading-how-the-layers-connect">How the Layers Connect</h3>
<p>Here's the full picture of how data flows through the four layers for a read operation:</p>
<pre><code class="language-text">User clicks "Dashboard"
  → TanStack Router triggers the route loader
    → Loader calls api.api.purchases.status.get() via Eden Treaty
      → Elysia receives GET /api/purchases/status
        → Handler calls auth.api.getSession() to verify the user
        → Handler queries db.select().from(purchases) via Drizzle
        → Handler returns { purchase } with inferred types
      → Eden Treaty receives typed response
    → Loader returns typed data
  → Component renders with Route.useLoaderData()
</code></pre>
<p>For a write operation (creating a new resource), the flow is similar but uses a mutation:</p>
<pre><code class="language-text">User clicks "Buy Now"
  → onClick calls checkout.mutate() from useMutation hook
    → mutationFn calls api.api.payments.checkout.post() via Eden Treaty
      → Elysia receives POST /api/payments/checkout
        → Handler creates a Stripe checkout session
        → Handler returns { url }
      → Eden Treaty receives typed response
    → onSuccess redirects to Stripe Checkout
</code></pre>
<h3 id="heading-how-to-add-a-second-feature">How to Add a Second Feature</h3>
<p>To cement the pattern, let's walk through adding a user profile update feature. This shows all four layers for a write operation.</p>
<p><strong>Layer 1: Schema.</strong> The <code>users</code> table already has a <code>name</code> field you can update. No schema change needed.</p>
<p><strong>Layer 2: API.</strong> Add a PATCH endpoint:</p>
<pre><code class="language-typescript">// In src/server/api.ts
.patch(
  "/me",
  async ({ request, body, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    const [updatedUser] = await db
      .update(users)
      .set({
        name: body.name,
        updatedAt: new Date(),
      })
      .where(eq(users.id, session.user.id))
      .returning();

    return { user: updatedUser };
  },
  {
    body: t.Object({
      name: t.String({ minLength: 1, maxLength: 100 }),
    }),
  },
)
</code></pre>
<p>The <code>body</code> option validates the request body at runtime and provides TypeScript types at compile time. If someone sends a request without a <code>name</code> field, Elysia returns a 400 error automatically. You don't write any validation logic yourself.</p>
<p><strong>Layer 3: Hooks.</strong> Create a mutation hook:</p>
<pre><code class="language-typescript">// src/hooks/use-update-profile.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";

import { api } from "@/lib/treaty";

export function useUpdateProfile() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (data: { name: string }) =&gt; {
      const { data: result, error } = await api.api.me.patch(data);
      if (error) throw new Error("Failed to update profile");
      return result;
    },
    onSuccess: () =&gt; {
      // Invalidate any queries that depend on user data
      queryClient.invalidateQueries({ queryKey: ["me"] });
    },
  });
}
</code></pre>
<p>The <code>onSuccess</code> callback invalidates the cache for user-related queries. This means any component displaying user data will automatically refetch and show the updated name.</p>
<p><strong>Layer 4: UI.</strong> Use the hook in a form component:</p>
<pre><code class="language-tsx">// src/components/profile-form.tsx
import { useState } from "react";

import { useUpdateProfile } from "@/hooks/use-update-profile";

export function ProfileForm({ currentName }: { currentName: string }) {
  const [name, setName] = useState(currentName);
  const updateProfile = useUpdateProfile();

  const handleSubmit = (e: React.FormEvent) =&gt; {
    e.preventDefault();
    updateProfile.mutate({ name });
  };

  return (
    &lt;form onSubmit={handleSubmit}&gt;
      &lt;label htmlFor="name" className="block text-sm font-medium"&gt;
        Display Name
      &lt;/label&gt;
      &lt;input
        id="name"
        type="text"
        value={name}
        onChange={(e) =&gt; setName(e.target.value)}
        className="mt-1 block w-full rounded-md border px-3 py-2"
      /&gt;
      &lt;button
        type="submit"
        disabled={updateProfile.isPending}
        className="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white"
      &gt;
        {updateProfile.isPending ? "Saving..." : "Save"}
      &lt;/button&gt;
      {updateProfile.isError &amp;&amp; (
        &lt;p className="mt-2 text-sm text-red-600"&gt;
          Failed to update profile. Please try again.
        &lt;/p&gt;
      )}
    &lt;/form&gt;
  );
}
</code></pre>
<p>Four layers, second feature. The pattern is identical every time.</p>
<p>The pattern is deliberately repetitive. Repetition is a feature, not a bug. When every feature follows the same structure, you always know where to look.</p>
<p>New code goes in predictable places. And if you use an AI coding assistant, it can learn this pattern from your codebase and generate all four layers for new features.</p>
<h2 id="heading-how-to-add-payments-with-stripe">How to Add Payments with Stripe</h2>
<p>Most SaaS applications need to collect payments. You'll integrate Stripe for one-time purchases using Stripe Checkout. The key architectural decision is handling webhooks reliably using background jobs, which you'll add in the next section.</p>
<h3 id="heading-how-to-set-up-stripe">How to Set Up Stripe</h3>
<p>Create <code>src/lib/payments/index.ts</code>:</p>
<pre><code class="language-typescript">// src/lib/payments/index.ts
import Stripe from "stripe";

let stripeClient: Stripe | null = null;

function getStripe(): Stripe {
  if (!stripeClient) {
    const secretKey = process.env.STRIPE_SECRET_KEY;
    if (!secretKey) {
      throw new Error(
        "STRIPE_SECRET_KEY is not set. Payment functionality is unavailable."
      );
    }
    stripeClient = new Stripe(secretKey);
  }
  return stripeClient;
}

// Lazy-initialized proxy so imports don't crash without env vars
export const stripe = new Proxy({} as Stripe, {
  get(_, prop) {
    return Reflect.get(getStripe(), prop);
  },
});

export async function createOneTimeCheckoutSession(params: {
  priceId: string;
  successUrl: string;
  cancelUrl: string;
  metadata: Record&lt;string, string&gt;;
  customerEmail?: string;
  couponId?: string;
}) {
  const client = getStripe();

  const session = await client.checkout.sessions.create({
    mode: "payment",
    line_items: [{ price: params.priceId, quantity: 1 }],
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    metadata: params.metadata,
    ...(params.customerEmail &amp;&amp; {
      customer_email: params.customerEmail,
    }),
    ...(params.couponId
      ? { discounts: [{ coupon: params.couponId }] }
      : { allow_promotion_codes: true }),
  });

  return session;
}

export async function retrieveCheckoutSession(sessionId: string) {
  const client = getStripe();
  return client.checkout.sessions.retrieve(sessionId);
}

export async function constructWebhookEvent(
  payload: string | Buffer,
  signature: string
) {
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!webhookSecret) {
    throw new Error("STRIPE_WEBHOOK_SECRET is not set");
  }
  const client = getStripe();
  return client.webhooks.constructEventAsync(payload, signature, webhookSecret);
}
</code></pre>
<p>The <code>Proxy</code> pattern for the Stripe client is a production technique. It lazily initializes the Stripe SDK so your module can be imported without crashing if the <code>STRIPE_SECRET_KEY</code> environment variable is missing. This is useful during builds and in environments where not every service is configured.</p>
<h3 id="heading-how-to-create-the-checkout-endpoint">How to Create the Checkout Endpoint</h3>
<p>Add a checkout endpoint to your API:</p>
<pre><code class="language-typescript">// In src/server/api.ts
.post("/payments/checkout", async ({ set }) =&gt; {
  const priceId = process.env.STRIPE_PRO_PRICE_ID;

  if (!priceId) {
    set.status = 500;
    return { error: "Price not configured" };
  }

  const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";

  const checkoutSession = await createOneTimeCheckoutSession({
    priceId,
    successUrl: `${baseUrl}/dashboard?purchase=success&amp;session_id={CHECKOUT_SESSION_ID}`,
    cancelUrl: `${baseUrl}/pricing`,
    metadata: { tier: "pro" },
  });

  return { url: checkoutSession.url };
})
</code></pre>
<p>The <code>{CHECKOUT_SESSION_ID}</code> placeholder is a Stripe template variable. Stripe replaces it with the actual session ID when redirecting the user back to your app.</p>
<h3 id="heading-how-to-handle-webhooks">How to Handle Webhooks</h3>
<p>Stripe sends webhook events when payments are processed. Your webhook handler needs to verify the signature, parse the event, and process it.</p>
<p>Here's the critical design decision: don't do heavy processing inside the webhook handler. Stripe expects a response within a few seconds. If your handler takes too long, Stripe will retry the webhook, potentially causing duplicate processing.</p>
<p>Instead, use the "webhook receives, background job processes" pattern:</p>
<pre><code class="language-typescript">// In src/server/api.ts
.post("/payments/webhook", async ({ request, set }) =&gt; {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  if (!sig) {
    set.status = 400;
    return { error: "Missing signature" };
  }

  try {
    const event = await constructWebhookEvent(body, sig);
    console.log(`[Webhook] Received ${event.type}`);

    if (event.type === "charge.refunded") {
      const charge = event.data.object as {
        id: string;
        payment_intent: string;
        amount: number;
        amount_refunded: number;
        currency: string;
      };
      await inngest.send({
        name: "stripe/charge.refunded",
        data: {
          chargeId: charge.id,
          paymentIntentId: charge.payment_intent,
          amountRefunded: charge.amount_refunded,
          originalAmount: charge.amount,
          currency: charge.currency,
        },
      });
    }

    return { received: true };
  } catch (error) {
    console.error("[Webhook] Stripe verification failed:", error);
    set.status = 400;
    return { error: "Webhook verification failed" };
  }
})
</code></pre>
<p>The webhook handler does three things: verifies the signature, identifies the event type, and forwards the data to Inngest for background processing. It responds immediately with <code>{ received: true }</code>. The actual business logic (sending emails, granting access, updating records) happens in the background job, which you'll build next.</p>
<h3 id="heading-how-to-claim-purchases-on-the-frontend">How to Claim Purchases on the Frontend</h3>
<p>After a successful checkout, Stripe redirects the user back to your app with a session ID. You need an endpoint that claims the purchase by verifying the session and creating a database record:</p>
<pre><code class="language-typescript">// In src/server/api.ts
.post(
  "/purchases/claim",
  async ({ body, request, set }) =&gt; {
    const session = await auth.api.getSession({
      headers: request.headers,
    });

    if (!session) {
      set.status = 401;
      return { error: "Unauthorized" };
    }

    const { sessionId } = body;

    // Check if already claimed (idempotency)
    const existing = await db
      .select()
      .from(purchases)
      .where(eq(purchases.stripeCheckoutSessionId, sessionId))
      .limit(1);

    if (existing[0]) {
      return { success: true, alreadyClaimed: true, tier: existing[0].tier };
    }

    // Verify payment with Stripe
    const stripeSession = await retrieveCheckoutSession(sessionId);

    if (stripeSession.payment_status !== "paid") {
      set.status = 400;
      return { error: "Payment not completed" };
    }

    const tier = (stripeSession.metadata?.tier ?? "pro") as "pro";

    // Create purchase record
    await db.insert(purchases).values({
      userId: session.user.id,
      stripeCheckoutSessionId: sessionId,
      stripeCustomerId:
        typeof stripeSession.customer === "string"
          ? stripeSession.customer
          : stripeSession.customer?.id ?? null,
      stripePaymentIntentId:
        typeof stripeSession.payment_intent === "string"
          ? stripeSession.payment_intent
          : stripeSession.payment_intent?.id ?? null,
      tier,
      status: "completed",
      amount: stripeSession.amount_total ?? 0,
      currency: stripeSession.currency ?? "usd",
    });

    // Trigger background processing
    await inngest.send({
      name: "purchase/completed",
      data: {
        userId: session.user.id,
        tier,
        sessionId,
      },
    });

    return { success: true, tier };
  },
  {
    body: t.Object({
      sessionId: t.String(),
    }),
  }
)
</code></pre>
<p>Notice the idempotency check at the top. If the user refreshes the success page or the frontend retries the claim request, the endpoint returns the existing purchase instead of creating a duplicate.</p>
<p>This is essential for payment flows. You never want to accidentally charge someone twice or create duplicate records.</p>
<p>The <code>inngest.send()</code> call triggers background processing for the purchase. That's where you send confirmation emails, grant access to resources, track analytics events, and perform any other post-purchase work.</p>
<h3 id="heading-how-to-test-payments-locally">How to Test Payments Locally</h3>
<p>Install the Stripe CLI and forward webhooks to your local server:</p>
<pre><code class="language-bash"># Install Stripe CLI (macOS)
brew install stripe/stripe-cli/stripe

# Login to Stripe
stripe login

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/payments/webhook
</code></pre>
<p>The Stripe CLI gives you a webhook signing secret that starts with <code>whsec_</code>. Add it to your <code>.env</code>:</p>
<pre><code class="language-bash">STRIPE_WEBHOOK_SECRET=whsec_your-local-webhook-secret
</code></pre>
<p>Create a test product and price in your Stripe dashboard (or use the Stripe CLI), then add the price ID to your <code>.env</code>:</p>
<pre><code class="language-bash">STRIPE_SECRET_KEY=sk_test_your-test-secret-key
STRIPE_PRO_PRICE_ID=price_your-test-price-id
</code></pre>
<h2 id="heading-how-to-add-background-jobs-with-inngest">How to Add Background Jobs with Inngest</h2>
<p>Background jobs are critical for any SaaS. You use them for processing webhooks, sending emails, granting access to resources, and any work that shouldn't block your API response. Inngest provides durable, retry-able functions with built-in checkpointing.</p>
<h3 id="heading-why-background-jobs-matter">Why Background Jobs Matter</h3>
<p>Consider what happens when someone purchases your SaaS product:</p>
<ol>
<li><p>Verify the payment with Stripe</p>
</li>
<li><p>Create a purchase record in the database</p>
</li>
<li><p>Send a confirmation email to the customer</p>
</li>
<li><p>Send a notification email to the admin</p>
</li>
<li><p>Grant access to a private GitHub repository</p>
</li>
<li><p>Track the purchase event in your analytics platform</p>
</li>
<li><p>Schedule a follow-up email sequence</p>
</li>
</ol>
<p>If you try to do all of this inside an API endpoint, several things can go wrong. The email service might be down. The GitHub API might rate-limit you. Your analytics call might time out.</p>
<p>Any failure means the user sees an error, and you have to figure out which steps completed and which did not.</p>
<p>Inngest solves this with durable execution. Each step is checkpointed. If step 3 fails, Inngest retries step 3 without re-running steps 1 and 2.</p>
<p>If the entire function fails, Inngest retries the whole thing. You get at-least-once execution with automatic deduplication.</p>
<h3 id="heading-how-to-set-up-inngest">How to Set Up Inngest</h3>
<p>Create the Inngest client at <code>src/lib/jobs/client.ts</code>:</p>
<pre><code class="language-typescript">// src/lib/jobs/client.ts
import { Inngest } from "inngest";

export const inngest = new Inngest({
  id: "my-saas",
});
</code></pre>
<h3 id="heading-how-to-write-your-first-inngest-function">How to Write Your First Inngest Function</h3>
<p>Create <code>src/lib/jobs/functions/stripe.ts</code> with the purchase completion handler:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
import { eq } from "drizzle-orm";

import { inngest } from "../client";
import { db, purchases, users } from "@/lib/db";

export const handlePurchaseCompleted = inngest.createFunction(
  {
    id: "purchase-completed",
    triggers: [{ event: "purchase/completed" }],
  },
  async ({ event, step }) =&gt; {
    const { userId, tier, sessionId } = event.data as {
      userId: string;
      tier: string;
      sessionId: string;
    };

    // Step 1: Look up user and purchase details
    const { user, purchase } = await step.run(
      "lookup-user-and-purchase",
      async () =&gt; {
        const userResult = await db
          .select({
            id: users.id,
            email: users.email,
            name: users.name,
          })
          .from(users)
          .where(eq(users.id, userId))
          .limit(1);

        const foundUser = userResult[0];
        if (!foundUser) {
          throw new Error(`User not found: ${userId}`);
        }

        const purchaseResult = await db
          .select({
            amount: purchases.amount,
            currency: purchases.currency,
          })
          .from(purchases)
          .where(eq(purchases.stripeCheckoutSessionId, sessionId))
          .limit(1);

        return {
          user: foundUser,
          purchase: purchaseResult[0] ?? {
            amount: 0,
            currency: "usd",
          },
        };
      }
    );

    // Step 2: Send purchase confirmation email
    await step.run("send-purchase-confirmation", async () =&gt; {
      // Send email using your email service (Resend, SendGrid, and so on)
      console.log(
        `Sending purchase confirmation to ${user.email}`
      );
      // await sendEmail({
      //   to: user.email,
      //   subject: "Your purchase is confirmed!",
      //   template: PurchaseConfirmationEmail,
      // });
    });

    // Step 3: Send admin notification
    await step.run("send-admin-notification", async () =&gt; {
      const adminEmail = process.env.ADMIN_EMAIL;
      if (!adminEmail) return;

      console.log(
        `Notifying admin about purchase from ${user.email}`
      );
      // await sendEmail({
      //   to: adminEmail,
      //   subject: `New sale: ${user.email}`,
      //   template: AdminNotificationEmail,
      // });
    });

    // Step 4: Update purchase record
    await step.run("update-purchase-record", async () =&gt; {
      await db
        .update(purchases)
        .set({ updatedAt: new Date() })
        .where(eq(purchases.stripeCheckoutSessionId, sessionId));
    });

    return { success: true, userId, tier };
  }
);

export const stripeFunctions = [handlePurchaseCompleted];
</code></pre>
<p>Each <code>step.run()</code> is a checkpoint. If the function fails after step 2, Inngest retries from step 3, not from the beginning. The results of completed steps are cached.</p>
<h3 id="heading-how-to-register-your-functions">How to Register Your Functions</h3>
<p>Create an index file that collects all your functions:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/index.ts
import { stripeFunctions } from "./stripe";

export const functions = [...stripeFunctions];
</code></pre>
<p>And a barrel export:</p>
<pre><code class="language-typescript">// src/lib/jobs/index.ts
export { inngest } from "./client";
export { functions } from "./functions";
</code></pre>
<h3 id="heading-how-to-connect-inngest-to-your-api">How to Connect Inngest to Your API</h3>
<p>Mount the Inngest handler in your Elysia API. Add this to <code>src/server/api.ts</code>:</p>
<pre><code class="language-typescript">// src/server/api.ts
import { serve } from "inngest/bun";

import { inngest, functions } from "@/lib/jobs";

const inngestHandler = serve({
  client: inngest,
  functions,
});

export const api = new Elysia({ prefix: "/api" })
  // Inngest endpoint - handles function registration and execution
  .all("/inngest", async (ctx) =&gt; {
    return inngestHandler(ctx.request);
  })
  // ... rest of your routes
</code></pre>
<p>The <code>.all("/inngest")</code> route handles both GET (for function registration) and POST (for function execution) requests from Inngest.</p>
<h3 id="heading-how-to-run-inngest-locally">How to Run Inngest Locally</h3>
<p>Inngest provides a dev server that runs locally and provides a dashboard for monitoring your functions:</p>
<pre><code class="language-bash">npx inngest-cli@latest dev -u http://localhost:3000/api/inngest --no-discovery
</code></pre>
<p>This starts the Inngest dev server at <code>http://localhost:8288</code>. Open that URL in your browser to see a dashboard showing your registered functions, event history, and function execution logs.</p>
<p>The <code>-u</code> flag tells Inngest where your app is running. The <code>--no-discovery</code> flag disables automatic app discovery, which is more reliable for local development.</p>
<p>Add this as a script in your <code>package.json</code>:</p>
<pre><code class="language-json">{
  "scripts": {
    "inngest:dev": "npx inngest-cli@latest dev -u http://localhost:3000/api/inngest --no-discovery"
  }
}
</code></pre>
<p>Now you can trigger your functions by sending events from your API:</p>
<pre><code class="language-typescript">await inngest.send({
  name: "purchase/completed",
  data: {
    userId: "user_123",
    tier: "pro",
    sessionId: "cs_test_abc",
  },
});
</code></pre>
<p>The event appears in the Inngest dashboard, the function executes step by step, and you can see the output of each step. If a step fails, you can retry it manually from the dashboard.</p>
<h3 id="heading-how-to-handle-refunds-with-background-jobs">How to Handle Refunds with Background Jobs</h3>
<p>Here's a more complex example that shows why durable execution matters. When processing a refund, you need to update the purchase status, revoke access, send notifications, and track analytics. If any step fails, the others should still complete:</p>
<pre><code class="language-typescript">// src/lib/jobs/functions/stripe.ts
export const handleRefund = inngest.createFunction(
  {
    id: "refund-processed",
    triggers: [{ event: "stripe/charge.refunded" }],
  },
  async ({ event, step }) =&gt; {
    const { paymentIntentId, amountRefunded, originalAmount, currency } =
      event.data as {
        chargeId: string;
        paymentIntentId: string;
        amountRefunded: number;
        originalAmount: number;
        currency: string;
      };

    const isFullRefund = amountRefunded &gt;= originalAmount;

    // Step 1: Find the purchase and user
    const { user, purchase } = await step.run(
      "lookup-purchase",
      async () =&gt; {
        const purchaseResult = await db
          .select()
          .from(purchases)
          .where(eq(purchases.stripePaymentIntentId, paymentIntentId))
          .limit(1);

        if (!purchaseResult[0]) {
          return { user: null, purchase: null };
        }

        const userResult = await db
          .select()
          .from(users)
          .where(eq(users.id, purchaseResult[0].userId))
          .limit(1);

        return {
          user: userResult[0] ?? null,
          purchase: purchaseResult[0],
        };
      }
    );

    if (!purchase || !user) {
      return { success: false, reason: "no_matching_purchase" };
    }

    // Step 2: Update purchase status
    await step.run("update-purchase-status", async () =&gt; {
      await db
        .update(purchases)
        .set({
          status: isFullRefund ? "refunded" : "partially_refunded",
          updatedAt: new Date(),
        })
        .where(eq(purchases.id, purchase.id));
    });

    // Step 3: Send customer notification
    await step.run("notify-customer", async () =&gt; {
      console.log(
        `Sending \({isFullRefund ? "full" : "partial"} refund notification to \){user.email}`
      );
      // await sendEmail({ ... });
    });

    return { success: true, isFullRefund };
  }
);
</code></pre>
<p>Even if the email service is down in step 3, step 2 (updating the database) has already completed and will not be re-run. Inngest retries only the failed step.</p>
<p>This is what makes durable execution valuable for payment processing. You get reliable, idempotent processing without building your own retry logic.</p>
<h2 id="heading-how-to-deploy-to-vercel-with-neon">How to Deploy to Vercel with Neon</h2>
<p>You now have a working application with authentication, a database, a type-safe API, payments, and background jobs. Time to deploy it.</p>
<h3 id="heading-how-to-provision-a-neon-database">How to Provision a Neon Database</h3>
<ol>
<li><p>Sign up at <a href="https://neon.tech">neon.tech</a> and create a new project</p>
</li>
<li><p>Choose a region close to your users (Neon supports multiple AWS regions)</p>
</li>
<li><p>Copy the connection string from the dashboard</p>
</li>
</ol>
<p>The connection string looks like this:</p>
<pre><code class="language-text">postgresql://username:password@ep-something.us-east-1.aws.neon.tech/my_saas?sslmode=require
</code></pre>
<h3 id="heading-how-to-run-migrations-in-production">How to Run Migrations in Production</h3>
<p>For production, you should use versioned migrations instead of <code>db:push</code>. Generate a migration from your schema:</p>
<pre><code class="language-bash">bun run db:generate
</code></pre>
<p>This creates SQL files in the <code>drizzle/</code> directory. Review the generated SQL to make sure it matches your expectations. Then apply the migration:</p>
<pre><code class="language-bash">DATABASE_URL="your-neon-connection-string" bun run db:migrate
</code></pre>
<h3 id="heading-how-to-deploy-to-vercel">How to Deploy to Vercel</h3>
<ol>
<li><p>Push your code to a GitHub repository</p>
</li>
<li><p>Go to <a href="https://vercel.com/new">vercel.com/new</a> and import your repository</p>
</li>
<li><p>Vercel will auto-detect TanStack Start and configure the build settings</p>
</li>
</ol>
<p>Set the following environment variables in Vercel's dashboard:</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>DATABASE_URL</code></td>
<td>Your Neon connection string</td>
</tr>
<tr>
<td><code>BETTER_AUTH_SECRET</code></td>
<td>Your random 32+ character string</td>
</tr>
<tr>
<td><code>BETTER_AUTH_URL</code></td>
<td><code>https://your-app.vercel.app</code></td>
</tr>
<tr>
<td><code>GITHUB_CLIENT_ID</code></td>
<td>Your GitHub OAuth client ID</td>
</tr>
<tr>
<td><code>GITHUB_CLIENT_SECRET</code></td>
<td>Your GitHub OAuth client secret</td>
</tr>
<tr>
<td><code>STRIPE_SECRET_KEY</code></td>
<td>Your Stripe secret key (live)</td>
</tr>
<tr>
<td><code>STRIPE_WEBHOOK_SECRET</code></td>
<td>Your Stripe webhook secret (production)</td>
</tr>
<tr>
<td><code>STRIPE_PRO_PRICE_ID</code></td>
<td>Your Stripe price ID</td>
</tr>
</tbody></table>
<p>Click "Deploy." Vercel builds your app and deploys it to a <code>.vercel.app</code> URL.</p>
<h3 id="heading-how-to-update-oauth-callbacks">How to Update OAuth Callbacks</h3>
<p>After deploying, update your GitHub OAuth app's callback URL:</p>
<ol>
<li><p>Go to your GitHub OAuth app settings</p>
</li>
<li><p>Change the <strong>Authorization callback URL</strong> to <code>https://your-app.vercel.app/api/auth/callback/github</code></p>
</li>
<li><p>Add <code>https://your-app.vercel.app</code> as the <strong>Homepage URL</strong></p>
</li>
</ol>
<h3 id="heading-how-to-configure-stripe-webhooks-for-production">How to Configure Stripe Webhooks for Production</h3>
<p>Create a webhook endpoint in the Stripe dashboard:</p>
<ol>
<li><p>Go to <a href="https://dashboard.stripe.com/webhooks">Stripe Dashboard &gt; Developers &gt; Webhooks</a></p>
</li>
<li><p>Click "Add endpoint"</p>
</li>
<li><p>Set the URL to <code>https://your-app.vercel.app/api/payments/webhook</code></p>
</li>
<li><p>Select the events you want to receive (<code>charge.refunded</code>, <code>checkout.session.expired</code>, and so on)</p>
</li>
<li><p>Copy the webhook signing secret and add it to Vercel's environment variables</p>
</li>
</ol>
<h3 id="heading-how-to-set-up-inngest-in-production">How to Set Up Inngest in Production</h3>
<p>Inngest has a cloud service that handles function execution in production:</p>
<ol>
<li><p>Sign up at <a href="https://www.inngest.com">inngest.com</a></p>
</li>
<li><p>Create an app and copy your event key and signing key</p>
</li>
<li><p>Add <code>INNGEST_EVENT_KEY</code> and <code>INNGEST_SIGNING_KEY</code> to Vercel's environment variables</p>
</li>
<li><p>In Inngest's dashboard, set your app URL to <code>https://your-app.vercel.app/api/inngest</code></p>
</li>
</ol>
<p>Inngest automatically discovers your functions and starts processing events.</p>
<h3 id="heading-common-deployment-pitfalls">Common Deployment Pitfalls</h3>
<p><strong>1. SSR externals.</strong> Some packages do not work with Vite's SSR bundling. If you see errors about packages like <code>elysia</code> or <code>inngest</code> during the build, add them to the <code>ssr.external</code> array in <code>vite.config.ts</code>:</p>
<pre><code class="language-typescript">// vite.config.ts
export default defineConfig({
  ssr: {
    external: ["elysia", "inngest"],
  },
  // ...
});
</code></pre>
<p><strong>2. Environment variable access.</strong> In TanStack Start, server-side code can access <code>process.env</code> directly. Client-side code can only access variables prefixed with <code>VITE_</code>. Your Stripe secret key and database URL should never have the <code>VITE_</code> prefix.</p>
<p><strong>3. Neon connection pooling.</strong> For production, use the pooled connection string from Neon (it uses port 5432 instead of the direct connection on port 5433). The pooled connection handles concurrent requests better.</p>
<p><strong>4. Build failures.</strong> If your build fails, the most common cause is a TypeScript error. Run <code>bun run type-check</code> locally before pushing. Fix all errors before deploying.</p>
<p><strong>5. Missing environment variables.</strong> If your app crashes immediately after deployment, check the Vercel function logs. The most common issue is a missing environment variable. Neon connection strings, Stripe keys, and Better Auth secrets all need to be set before the first deployment.</p>
<h3 id="heading-how-to-set-up-a-custom-domain">How to Set Up a Custom Domain</h3>
<p>Once your app is deployed to Vercel:</p>
<ol>
<li><p>Go to your project's Settings in Vercel</p>
</li>
<li><p>Click "Domains"</p>
</li>
<li><p>Add your custom domain</p>
</li>
<li><p>Update your DNS records as instructed (usually a CNAME record pointing to <code>cname.vercel-dns.com</code>)</p>
</li>
</ol>
<p>After adding a custom domain, update these environment variables in Vercel:</p>
<ul>
<li><p>Set <code>BETTER_AUTH_URL</code> to <code>https://yourdomain.com</code></p>
</li>
<li><p>Update your GitHub OAuth app's callback URL to <code>https://yourdomain.com/api/auth/callback/github</code></p>
</li>
<li><p>Update your Stripe webhook endpoint to <code>https://yourdomain.com/api/payments/webhook</code></p>
</li>
</ul>
<p>Vercel automatically provisions an SSL certificate for your custom domain. No additional configuration needed.</p>
<h3 id="heading-how-to-verify-your-deployment">How to Verify Your Deployment</h3>
<p>After deploying, run through this checklist:</p>
<ol>
<li><p><strong>Health check.</strong> Visit <code>https://yourdomain.com/api/health</code>. You should see a JSON response with <code>{ "status": "ok" }</code>.</p>
</li>
<li><p><strong>Authentication.</strong> Click "Sign in with GitHub" and complete the OAuth flow. You should be redirected to your dashboard.</p>
</li>
<li><p><strong>Database.</strong> After signing in, check your Neon dashboard. You should see a new row in the <code>users</code> table.</p>
</li>
<li><p><strong>Payments.</strong> On your pricing page, click "Buy" and use Stripe's test card (<code>4242 4242 4242 4242</code>) to complete a purchase. Check that a purchase record appears in your database.</p>
</li>
<li><p><strong>Background jobs.</strong> After a test purchase, check the Inngest dashboard. You should see a <code>purchase/completed</code> event and the corresponding function execution.</p>
</li>
</ol>
<p>If any of these steps fail, check the Vercel function logs (Settings, Functions, Logs) for error messages. Most deployment issues are misconfigured environment variables or missing webhook secrets.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You just built a production-ready SaaS application. Let's recap what you have:</p>
<ul>
<li><p><strong>TanStack Start</strong> handles server-side rendering, file-based routing, and the dev server</p>
</li>
<li><p><strong>Elysia</strong> provides a type-safe API embedded in the same process as your web app</p>
</li>
<li><p><strong>Eden Treaty</strong> gives you a fully typed API client with zero code generation</p>
</li>
<li><p><strong>Drizzle ORM with Neon</strong> handles your database with type-safe queries and serverless PostgreSQL</p>
</li>
<li><p><strong>Better Auth</strong> provides GitHub OAuth with session management and route protection</p>
</li>
<li><p><strong>Stripe</strong> processes payments with webhook handling</p>
</li>
<li><p><strong>Inngest</strong> runs reliable background jobs with automatic retries and checkpointing</p>
</li>
<li><p><strong>Vercel</strong> hosts everything with zero infrastructure management</p>
</li>
</ul>
<p>The four-layer pattern (Schema, API, Hooks, UI) gives you a repeatable process for adding new features. Every feature follows the same structure. Define the data, expose it through the API, connect it to React with hooks, and render it in your components.</p>
<p>This architecture scales well. The explicit boundaries between layers mean you can swap out individual pieces without rewriting everything.</p>
<p>If you outgrow Neon, switch to a self-hosted PostgreSQL. If you need a different payment provider, replace the Stripe module. The rest of the application doesn't change.</p>
<p>What you build next is up to you. Here are natural next steps:</p>
<ul>
<li><p><strong>Email notifications</strong> with <a href="https://resend.com">Resend</a> and <a href="https://react.email">React Email</a> for transactional emails (purchase confirmations, password resets, welcome sequences)</p>
</li>
<li><p><strong>Analytics</strong> with <a href="https://posthog.com">PostHog</a> for tracking user behavior and feature flags</p>
</li>
<li><p><strong>Error tracking</strong> with <a href="https://sentry.io">Sentry</a> for catching production errors before your users report them</p>
</li>
<li><p><strong>Content management</strong> with MDX for a blog or documentation section</p>
</li>
<li><p><strong>File uploads</strong> with S3-compatible storage for user-generated content</p>
</li>
</ul>
<p>The <code>src/lib/</code> pattern makes adding new integrations straightforward. Create a new directory, add an <code>index.ts</code>, and import it where you need it. Each integration stays isolated, so adding analytics does not affect your payment code.</p>
<p>If you want to skip the setup and start building your product immediately, <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=fullstack-saas-handbook">Eden Stack</a> includes everything from this article (and more), pre-configured and production-tested. It ships with 30+ Claude Code skills that encode the patterns described here, so AI coding assistants can generate features following your codebase conventions out of the box.</p>
<p>Whatever you build, build it with type safety. The feedback loop of "change the schema, see the errors, fix the errors" is the fastest way I know to ship reliable software.</p>
<p><em>Magnus Rodseth builds AI-native applications and is the creator of</em> <a href="https://eden-stack.com?utm_source=freecodecamp&amp;utm_medium=article&amp;utm_campaign=fullstack-saas-handbook"><em>Eden Stack</em></a><em>, a production-ready starter kit with 30+ Claude skills encoding production patterns for AI-native SaaS development.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The Bad Website Club is Running a Free Responsive Web Design Bootcamp Based on freeCodeCamp ]]>
                </title>
                <description>
                    <![CDATA[ Hi everyone! We (Jess, Carmen, and Eda) are excited to announce the next installation of our free and online bootcamp. We support learners as they work their way through the freeCodeCamp Responsive We ]]>
                </description>
                <link>https://www.freecodecamp.org/news/bad-website-club-bootcamp-based-on-freecodecamp-rwd-cert/</link>
                <guid isPermaLink="false">69ce8df10ff860b6defc7074</guid>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ freeCodeCamp.org ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ bootcamp ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Eda Eren ]]>
                </dc:creator>
                <pubDate>Thu, 02 Apr 2026 15:40:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9a8913d9-9bc5-4e9e-9119-1d9d429578a0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Hi everyone!</p>
<p>We (Jess, Carmen, and Eda) are excited to announce the next installation of our free and online bootcamp. We support learners as they work their way through the freeCodeCamp Responsive Web Design curriculum. The bootcamp will start Friday, April 24th, and will run for 10 weeks until Friday, July 3rd.</p>
<p>Here’s what to expect:</p>
<ul>
<li><p><strong>Live Streams:</strong> We’ll be streaming Monday to Friday at 15.00 UTC (<a href="https://everytimezone.com/s/6e8e42f0">you can check your timezone here</a>), where we’ll go through the course material together on our <a href="https://www.youtube.com/@badwebsiteclub">YouTube channel</a>, on <a href="https://www.twitch.tv/jesslynnrose">Jess' Twitch channel</a> and <a href="https://www.twitch.tv/hola_soy_milk_">Carmen's Twitch channel</a>. It’s also a chance to ask your questions! If you can’t join us live, the streams will be available offline to watch later.</p>
</li>
<li><p><strong>Guest Sessions:</strong> We’ll expand on our studies with guest sessions where professionals working in software engineering and related fields will talk about their craft.</p>
</li>
<li><p><strong>Community:</strong> To support each other and work together, we have a dedicated Discord channel in the freeCodeCamp Discord server (you can join here: <a href="https://discord.gg/KVUmVXA">https://discord.gg/KVUmVXA</a>) to help you establish better connections with other learners working on freeCodeCamp material. We’ll also be including shared notes on our <a href="https://badwebsite.club">website</a>.</p>
</li>
<li><p><strong>Calendar:</strong> We have a <a href="https://badwebsite.club/calendars/spring-2026-lessons.ics">shared calendar</a> of lessons we’ll be covering with a supportive and friendly group of learners alongside you.</p>
</li>
<li><p><strong>Newsletter:</strong> There’s no signup needed, but if you want weekly emails, you can also <a href="https://buttondown.com/bad-website-club">sign up for our newsletter</a>.</p>
</li>
</ul>
<h2 id="heading-how-does-the-bootcamp-work">How Does the Bootcamp Work?</h2>
<p>Bad Website Club bootcamps have been running for a while, so if you've joined us in the past, this one is going to feel a little different.</p>
<p>As the freeCodeCamp course material has expanded, we have adapted our learning structure to reflect what’s in it.</p>
<p>We’re experimenting with a flipped classroom model, where learners do pre-reading and more solo work before classes to come into streamed sessions, prepared to support each other’s learning.</p>
<p>Also, learners will be able to contribute to shared lesson notes which we’ll be going over in unit reviews. We’ll be talking through some (not all!) of the workshop steps and offer space for Q&amp;A in our live streams.</p>
<p>The labs and certification projects need to be done as solo work, but we’ll be looking at how to approach them together on streams and covering them in Q&amp;As.</p>
<p>We’ll be moving fast, but there are no fixed deadlines! If you need more time, your progress will be saved on your freeCodeCamp account. Also, the videos and our Discord are available after the bootcamp ends.</p>
<p>Note that the bootcamp doesn't offer job placement support or 1:1 instructor support for learners due to the size and global distribution of our learners. But we’ll help you learn the skills that prepare you to pursue opportunities on your own.</p>
<h2 id="heading-who-we-are">Who We Are</h2>
<p>Bad Website Club has been running free and online developer education programs since 2020. It's run by a small volunteer team (<a href="https://jessica.tech/">Jessica Rose</a>, <a href="https://carmenh.dev/">Carmen Huidobro</a>, and <a href="https://edaeren.com/">Eda Eren</a> – also thank you to wonderful <a href="https://kirionearth.com/">Kiri</a> who made all the art!). We focus on learning and experimenting over perfection, and believe that the web can be better when it includes everyone.</p>
<p>Also, it really is free: there’s no way to pay for it.</p>
<p>If you want to join us, you can <a href="https://badwebsite.club/calendars/spring-2026-lessons.ics">download or subscribe to the full lesson calendar</a> (for Google Calendar, use the iCal feed with <a href="https://support.google.com/calendar/answer/37100?hl=en">these subscription instructions</a>), follow us on our <a href="https://www.youtube.com/@badwebsiteclub">YouTube channel</a>, on <a href="https://www.twitch.tv/jesslynnrose">Jess' Twitch channel</a> and <a href="https://www.twitch.tv/hola_soy_milk_">Carmen's Twitch channel</a>. You can also sign up for our weekly newsletter for lesson notes, show and tell projects, extra resources, and more.</p>
<p>Hope to see you soon!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Local SEO Audit Agent with Browser Use and Claude API ]]>
                </title>
                <description>
                    <![CDATA[ Every digital marketing agency has someone whose job involves opening a spreadsheet, visiting each client URL, checking the title tag, meta description, and H1, noting broken links, and pasting everyt ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-local-seo-audit-agent-with-browser-use-and-claude-api/</link>
                <guid isPermaLink="false">69cb09249fffa747409f133f</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Python 3 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ claude.ai ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Daniel Nwaneri ]]>
                </dc:creator>
                <pubDate>Mon, 30 Mar 2026 23:37:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/98f8eb73-bfe2-4990-b41a-1997a35134f2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every digital marketing agency has someone whose job involves opening a spreadsheet, visiting each client URL, checking the title tag, meta description, and H1, noting broken links, and pasting everything into a report. Then doing it again next week.</p>
<p>That work is deterministic. An agent can do it.</p>
<p>In this tutorial, you'll build a local SEO audit agent from scratch using Python, Browser Use, and the Claude API. The agent visits real pages in a visible browser window, extracts SEO signals using Claude, checks for broken links asynchronously, handles edge cases with a human-in-the-loop pause, and writes a structured report — all resumable if interrupted.</p>
<p>By the end, you'll have a working agent you can run against any list of URLs. It costs less than $0.01 per URL to run.</p>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>A seven-module Python agent that:</p>
<ul>
<li><p>Reads a URL list from a CSV file</p>
</li>
<li><p>Visits each URL in a real Chromium browser (not a headless scraper)</p>
</li>
<li><p>Extracts title, meta description, H1s, and canonical tag via Claude API</p>
</li>
<li><p>Checks for broken links asynchronously using httpx</p>
</li>
<li><p>Detects edge cases (404s, login walls, redirects) and pauses for human input</p>
</li>
<li><p>Writes results to <code>report.json</code> incrementally — safe to interrupt and resume</p>
</li>
<li><p>Generates a plain-English <code>report-summary.txt</code> on completion</p>
</li>
</ul>
<p>The full code is on GitHub at <a href="https://github.com/dannwaneri/seo-agent">dannwaneri/seo-agent</a>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Python 3.11 or higher</p>
</li>
<li><p>An Anthropic API key (get one at console.anthropic.com)</p>
</li>
<li><p>Windows, macOS, or Linux</p>
</li>
<li><p>Basic familiarity with Python and the command line</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-why-browser-use-instead-of-a-scraper">Why Browser Use Instead of a Scraper</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-setup">Setup</a></p>
</li>
<li><p><a href="#heading-module-1-state-management">Module 1: State Management</a></p>
</li>
<li><p><a href="#heading-module-2-browser-integration">Module 2: Browser Integration</a></p>
</li>
<li><p><a href="#heading-module-3-claude-extraction-layer">Module 3: Claude Extraction Layer</a></p>
</li>
<li><p><a href="#heading-module-4-broken-link-checker">Module 4: Broken Link Checker</a></p>
</li>
<li><p><a href="#heading-module-5-human-in-the-loop">Module 5: Human-in-the-Loop</a></p>
</li>
<li><p><a href="#heading-module-6-report-writer">Module 6: Report Writer</a></p>
</li>
<li><p><a href="#heading-module-7-the-main-loop">Module 7: The Main Loop</a></p>
</li>
<li><p><a href="#heading-running-the-agent">Running the Agent</a></p>
</li>
<li><p><a href="#heading-scheduling-for-agency-use">Scheduling for Agency Use</a></p>
</li>
<li><p><a href="#heading-what-the-results-look-like">What the Results Look Like</a></p>
</li>
</ol>
<h2 id="heading-why-browser-use-instead-of-a-scraper">Why Browser Use Instead of a Scraper</h2>
<p>The standard approach to SEO auditing is to fetch page HTML with <code>requests</code> and parse it with BeautifulSoup. That works on static pages. It breaks on JavaScript-rendered content, misses dynamically injected meta tags, and fails entirely on authenticated pages.</p>
<p>Browser Use (84,000+ GitHub stars, MIT license) takes a different approach. It controls a real Chromium browser, reads the DOM after JavaScript executes, and exposes the page through Playwright's accessibility tree. The agent sees what a human would see.</p>
<p>The practical difference: a requests-based scraper might miss a meta description injected by a React component. Browser Use won't.</p>
<p>The other difference worth naming: Browser Use reads pages semantically. A Playwright script breaks when a button's CSS class changes from <code>btn-primary</code> to <code>button-main</code>. Browser Use identifies it's still a "Submit" button and acts accordingly. The extraction logic lives in the Claude prompt, not in brittle CSS selectors.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<pre><code class="language-plaintext">seo-agent/
├── index.py          # Main audit loop
├── browser.py        # Browser Use / Playwright page driver
├── extractor.py      # Claude API extraction layer
├── linkchecker.py    # Async broken link checker
├── hitl.py           # Human-in-the-loop pause logic
├── reporter.py       # Report writer
├── state.py          # State persistence (resume on interrupt)
├── input.csv         # Your URL list
├── requirements.txt
├── .env.example
└── .gitignore
</code></pre>
<h2 id="heading-setup">Setup</h2>
<p>Create a project folder and install dependencies:</p>
<pre><code class="language-bash">mkdir seo-agent &amp;&amp; cd seo-agent
pip install browser-use anthropic playwright httpx
playwright install chromium
</code></pre>
<p>Create <code>input.csv</code> with your URLs:</p>
<pre><code class="language-plaintext">url
https://example.com
https://example.com/about
https://example.com/contact
</code></pre>
<p>Create <code>.env.example</code>:</p>
<pre><code class="language-plaintext">ANTHROPIC_API_KEY=your-key-here
</code></pre>
<p>Set your API key as an environment variable before running:</p>
<pre><code class="language-bash"># macOS/Linux
export ANTHROPIC_API_KEY="sk-ant-..."

# Windows PowerShell
$env:ANTHROPIC_API_KEY = "sk-ant-..."
</code></pre>
<p>Create <code>.gitignore</code>:</p>
<pre><code class="language-plaintext">state.json
report.json
report-summary.txt
.env
__pycache__/
*.pyc
</code></pre>
<h2 id="heading-module-1-state-management">Module 1: State Management</h2>
<p>The agent needs to track which URLs it has already audited. If the run is interrupted — power cut, keyboard interrupt, network error — it should resume from where it stopped, not start over.</p>
<p><code>state.py</code> handles this with a flat JSON file:</p>
<pre><code class="language-python">import json
import os

STATE_FILE = os.path.join(os.path.dirname(__file__), "state.json")

_DEFAULT_STATE = {"audited": [], "pending": [], "needs_human": []}


def load_state() -&gt; dict:
    if not os.path.exists(STATE_FILE):
        save_state(_DEFAULT_STATE.copy())
    with open(STATE_FILE, encoding="utf-8") as f:
        return json.load(f)


def save_state(state: dict) -&gt; None:
    with open(STATE_FILE, "w", encoding="utf-8") as f:
        json.dump(state, f, indent=2)


def is_audited(url: str) -&gt; bool:
    return url in load_state()["audited"]


def mark_audited(url: str) -&gt; None:
    state = load_state()
    if url not in state["audited"]:
        state["audited"].append(url)
    save_state(state)


def add_to_needs_human(url: str) -&gt; None:
    state = load_state()
    if url not in state["needs_human"]:
        state["needs_human"].append(url)
    save_state(state)
</code></pre>
<p>The design is intentional: <code>mark_audited()</code> is called immediately after a URL is processed and written to the report. If the agent crashes mid-run, it loses at most one URL's work.</p>
<h2 id="heading-module-2-browser-integration">Module 2: Browser Integration</h2>
<p><code>browser.py</code> does the actual page navigation. It uses Playwright directly (which Browser Use installs as a dependency) to open a visible Chromium window, navigate to the URL, capture HTTP status and redirect information, and extract the raw SEO signals from the DOM.</p>
<p>The key design decisions:</p>
<p><strong>Visible browser, not headless.</strong> Set <code>headless=False</code> so you can watch the agent work. This matters for the demo and for debugging.</p>
<p><strong>Status capture via response listener.</strong> Playwright raises an exception on 4xx/5xx responses, but the <code>on("response", ...)</code> handler fires before the exception. We capture status there.</p>
<p><strong>2-second delay between visits.</strong> Prevents triggering rate limiting or bot detection on agency client sites.</p>
<p>Here is the core navigation function:</p>
<pre><code class="language-python">import asyncio
import sys
import time
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout

TIMEOUT = 20_000  # 20 seconds


def fetch_page(url: str) -&gt; dict:
    result = {
        "final_url": url,
        "status_code": None,
        "title": None,
        "meta_description": None,
        "h1s": [],
        "canonical": None,
        "raw_links": [],
    }

    first_status = {"code": None}

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        page = browser.new_page()

        def on_response(response):
            if first_status["code"] is None:
                first_status["code"] = response.status

        page.on("response", on_response)

        try:
            page.goto(url, wait_until="domcontentloaded", timeout=TIMEOUT)
            result["status_code"] = first_status["code"] or 200
            result["final_url"] = page.url

            # Extract SEO signals from DOM
            result["title"] = page.title() or None
            result["meta_description"] = page.evaluate(
                "() =&gt; { const m = document.querySelector('meta[name=\"description\"]'); "
                "return m ? m.getAttribute('content') : null; }"
            )
            result["h1s"] = page.evaluate(
                "() =&gt; Array.from(document.querySelectorAll('h1')).map(h =&gt; h.innerText.trim())"
            )
            result["canonical"] = page.evaluate(
                "() =&gt; { const c = document.querySelector('link[rel=\"canonical\"]'); "
                "return c ? c.getAttribute('href') : null; }"
            )
            result["raw_links"] = page.evaluate(
                "() =&gt; Array.from(document.querySelectorAll('a[href]'))"
                ".map(a =&gt; a.href).filter(Boolean).slice(0, 100)"
            )

        except PlaywrightTimeout:
            result["status_code"] = first_status["code"] or 408
        except Exception as exc:
            print(f"[browser] Error: {exc}", file=sys.stderr)
            result["status_code"] = first_status["code"]
        finally:
            browser.close()

    time.sleep(2)
    return result
</code></pre>
<p>A few things worth noting:</p>
<p>The <code>raw_links</code> cap at 100 is deliberate. DEV.to profile pages have hundreds of links — you don't need all of them for broken link detection.</p>
<p>The <code>wait_until="domcontentloaded"</code> setting is faster than <code>networkidle</code> and sufficient for meta tag extraction. JavaScript-rendered content needs the DOM to be ready, not all network requests to complete.</p>
<h2 id="heading-module-3-claude-extraction-layer">Module 3: Claude Extraction Layer</h2>
<p><code>extractor.py</code> takes the raw page snapshot from <code>browser.py</code> and calls Claude to produce a structured SEO audit result.</p>
<p>This is where most tutorials go wrong. They either write complex parsing logic in Python (fragile) or ask Claude for a free-form response and try to parse prose (unreliable). The right approach: give Claude a strict JSON schema and tell it to return nothing else.</p>
<p><strong>The prompt engineering that makes this reliable:</strong></p>
<pre><code class="language-python">import json
import os
import sys
from datetime import datetime, timezone
import anthropic

MODEL = "claude-sonnet-4-20250514"
client = anthropic.Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY"))


def _strip_fences(text: str) -&gt; str:
    """Remove accidental markdown code fences from Claude's response."""
    text = text.strip()
    if text.startswith("```"):
        lines = text.splitlines()
        # Drop opening fence
        lines = lines[1:] if lines[0].startswith("```") else lines
        # Drop closing fence
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()
    return text


def extract(snapshot: dict) -&gt; dict:
    if not os.environ.get("ANTHROPIC_API_KEY"):
        raise OSError("ANTHROPIC_API_KEY is not set.")

    prompt = f"""You are an SEO auditor. Analyze this page snapshot and return ONLY a JSON object.
No prose. No explanation. No markdown fences. Raw JSON only.

Page data:
- URL: {snapshot.get('final_url')}
- Status code: {snapshot.get('status_code')}
- Title: {snapshot.get('title')}
- Meta description: {snapshot.get('meta_description')}
- H1 tags: {snapshot.get('h1s')}
- Canonical: {snapshot.get('canonical')}

Return this exact schema:
{{
  "url": "string",
  "final_url": "string",
  "status_code": number,
  "title": {{"value": "string or null", "length": number, "status": "PASS or FAIL"}},
  "description": {{"value": "string or null", "length": number, "status": "PASS or FAIL"}},
  "h1": {{"count": number, "value": "string or null", "status": "PASS or FAIL"}},
  "canonical": {{"value": "string or null", "status": "PASS or FAIL"}},
  "flags": ["array of strings describing specific issues"],
  "human_review": false,
  "audited_at": "ISO timestamp"
}}

PASS/FAIL rules:
- title: FAIL if null or length &gt; 60 characters
- description: FAIL if null or length &gt; 160 characters  
- h1: FAIL if count is 0 (missing) or count &gt; 1 (multiple)
- canonical: FAIL if null
- flags: list every failing field with a clear description
- audited_at: use current UTC time in ISO 8601 format"""

    response = client.messages.create(
        model=MODEL,
        max_tokens=1000,
        messages=[{"role": "user", "content": prompt}],
    )

    raw = response.content[0].text
    clean = _strip_fences(raw)

    try:
        return json.loads(clean)
    except json.JSONDecodeError as exc:
        print(f"[extractor] JSON parse error: {exc}", file=sys.stderr)
        return _error_result(snapshot, str(exc))


def _error_result(snapshot: dict, reason: str) -&gt; dict:
    return {
        "url": snapshot.get("final_url", ""),
        "final_url": snapshot.get("final_url", ""),
        "status_code": snapshot.get("status_code"),
        "title": {"value": None, "length": 0, "status": "ERROR"},
        "description": {"value": None, "length": 0, "status": "ERROR"},
        "h1": {"count": 0, "value": None, "status": "ERROR"},
        "canonical": {"value": None, "status": "ERROR"},
        "flags": [f"Extraction error: {reason}"],
        "human_review": True,
        "audited_at": datetime.now(timezone.utc).isoformat(),
    }
</code></pre>
<p>Two things make this reliable in production:</p>
<p>First, <code>_strip_fences()</code> handles the case where Claude wraps its response in <code>```json</code> fences despite being told not to. This happens occasionally with Sonnet and consistently breaks <code>json.loads()</code> if you don't handle it.</p>
<p>Second, the <code>_error_result()</code> fallback means the agent never crashes on a bad Claude response — it logs the error and marks the URL for human review, then continues to the next URL.</p>
<p><strong>Cost:</strong> Claude Sonnet 4 is priced at \(3 per million input tokens and \)15 per million output tokens. A typical page snapshot is around 500 input tokens; the structured JSON response is around 300 output tokens. That works out to roughly \(0.006 per URL — about \)0.12 for a 20-URL audit.</p>
<h2 id="heading-module-4-broken-link-checker">Module 4: Broken Link Checker</h2>
<p><code>linkchecker.py</code> takes the <code>raw_links</code> list from the browser snapshot and checks same-domain links for broken status using async HEAD requests.</p>
<p>The design choices:</p>
<ul>
<li><p><strong>Same-domain only.</strong> Checking every external link on a page would take minutes and isn't what agency clients need. Filter to links on the same domain as the page being audited.</p>
</li>
<li><p><strong>HEAD requests, not GET.</strong> Faster, lower bandwidth, sufficient for status code detection.</p>
</li>
<li><p><strong>Cap at 50 links.</strong> Pages like DEV.to article listings have hundreds of internal links. Checking all of them would dominate the runtime.</p>
</li>
<li><p><strong>Concurrent requests via asyncio.</strong> All links are checked in parallel, not sequentially.</p>
</li>
</ul>
<pre><code class="language-python">import asyncio
import logging
from urllib.parse import urlparse
import httpx

CAP = 50
TIMEOUT = 5.0
logger = logging.getLogger(__name__)


def _same_domain(link: str, final_url: str) -&gt; bool:
    if not link:
        return False
    lower = link.strip().lower()
    if lower.startswith(("#", "mailto:", "javascript:", "tel:", "data:")):
        return False
    try:
        page_host = urlparse(final_url).netloc.lower()
        parsed = urlparse(link)
        return parsed.scheme in ("http", "https") and parsed.netloc.lower() == page_host
    except Exception:
        return False


async def _check_link(client: httpx.AsyncClient, url: str) -&gt; tuple[str, bool]:
    try:
        resp = await client.head(url, follow_redirects=True, timeout=TIMEOUT)
        return url, resp.status_code != 200
    except Exception:
        return url, True  # Timeout or connection error = broken


async def _run_checks(links: list[str]) -&gt; list[str]:
    async with httpx.AsyncClient() as client:
        results = await asyncio.gather(*[_check_link(client, url) for url in links])
    return [url for url, broken in results if broken]


def check_links(raw_links: list[str], final_url: str) -&gt; dict:
    same_domain = [l for l in raw_links if _same_domain(l, final_url)]

    capped = len(same_domain) &gt; CAP
    if capped:
        logger.warning("Page has %d same-domain links — capping at %d.", len(same_domain), CAP)
        same_domain = same_domain[:CAP]

    broken = asyncio.run(_run_checks(same_domain))

    return {
        "broken": broken,
        "count": len(broken),
        "status": "FAIL" if broken else "PASS",
        "capped": capped,
    }
</code></pre>
<h2 id="heading-module-5-human-in-the-loop">Module 5: Human-in-the-Loop</h2>
<p>This is the part most automation tutorials skip. What happens when the agent hits a login wall? A page that returns 403? A URL that redirects to a "Subscribe to continue reading" page?</p>
<p>Most scripts either crash or silently skip. Neither is acceptable in an agency context.</p>
<p><code>hitl.py</code> handles this with two functions: one that detects whether a pause is needed, and one that handles the pause itself.</p>
<pre><code class="language-python">from state import add_to_needs_human

LOGIN_KEYWORDS = {"login", "sign in", "sign-in", "access denied", "log in", "unauthorized"}
REDIRECT_CODES = {301, 302, 307, 308}


def should_pause(snapshot: dict) -&gt; bool:
    code = snapshot.get("status_code")

    # Navigation failed entirely
    if code is None:
        return True

    # Non-200, non-redirect
    if code != 200 and code not in REDIRECT_CODES:
        return True

    # Login wall detection
    title = (snapshot.get("title") or "").lower()
    h1s = [h.lower() for h in (snapshot.get("h1s") or [])]

    if any(kw in title for kw in LOGIN_KEYWORDS):
        return True
    if any(kw in h1 for kw in LOGIN_KEYWORDS for h1 in h1s):
        return True

    return False


def pause_reason(snapshot: dict) -&gt; str:
    code = snapshot.get("status_code")
    if code is None:
        return "Navigation failed (None status)"
    if code != 200 and code not in REDIRECT_CODES:
        return f"Unexpected status code: {code}"
    return "Possible login wall detected"


def pause_and_prompt(url: str, reason: str) -&gt; str:
    print(f"\n⚠️  HUMAN REVIEW NEEDED")
    print(f"   URL:    {url}")
    print(f"   Reason: {reason}")
    print(f"   Options: [s] skip  [r] retry  [q] quit\n")

    while True:
        choice = input("Your choice: ").strip().lower()
        if choice in ("s", "r", "q"):
            return {"s": "skip", "r": "retry", "q": "quit"}[choice]
        print("   Enter s, r, or q.")
</code></pre>
<p>The <code>should_pause()</code> function catches four cases: navigation failure, unexpected HTTP status, login keywords in the title, and login keywords in H1 tags. The login keyword check is what catches "Please sign in to continue" pages that return 200 but are effectively inaccessible.</p>
<p>In <code>--auto</code> mode (for scheduled runs), the main loop skips the <code>pause_and_prompt()</code> call and automatically handles these cases by logging the URL to <code>needs_human[]</code> in state and continuing.</p>
<h2 id="heading-module-6-report-writer">Module 6: Report Writer</h2>
<p><code>reporter.py</code> writes results incrementally. This is important: results are written after each URL is audited, not batched at the end. If the run is interrupted, you don't lose completed work.</p>
<pre><code class="language-python">import json
import os
from datetime import datetime, timezone

REPORT_JSON = os.path.join(os.path.dirname(__file__), "report.json")
REPORT_TXT = os.path.join(os.path.dirname(__file__), "report-summary.txt")


def _load_report() -&gt; list:
    if not os.path.exists(REPORT_JSON):
        return []
    with open(REPORT_JSON, encoding="utf-8") as f:
        return json.load(f)


def write_result(result: dict) -&gt; None:
    """Append or update a result in report.json."""
    entries = _load_report()
    url = result.get("url", "")

    # Update existing entry if URL already present (handles retries)
    for i, entry in enumerate(entries):
        if entry.get("url") == url:
            entries[i] = result
            break
    else:
        entries.append(result)

    with open(REPORT_JSON, "w", encoding="utf-8") as f:
        json.dump(entries, f, indent=2, ensure_ascii=False)


def _is_overall_pass(result: dict) -&gt; bool:
    fields = ["title", "description", "h1", "canonical"]
    for field in fields:
        if result.get(field, {}).get("status") not in ("PASS",):
            return False
    if result.get("broken_links", {}).get("status") == "FAIL":
        return False
    return True


def write_summary() -&gt; None:
    entries = _load_report()
    passed = sum(1 for e in entries if _is_overall_pass(e))

    lines = []
    for entry in entries:
        overall = "PASS" if _is_overall_pass(entry) else "FAIL"
        failed_fields = [
            f for f in ["title", "description", "h1", "canonical", "broken_links"]
            if entry.get(f, {}).get("status") == "FAIL"
        ]
        suffix = f" [{', '.join(failed_fields)}]" if failed_fields else ""
        lines.append(f"{entry.get('url', 'unknown'):&lt;60} | {overall}{suffix}")

    lines.append("")
    lines.append(f"{passed}/{len(entries)} URLs passed")

    with open(REPORT_TXT, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))
</code></pre>
<p>The deduplication in <code>write_result()</code> handles retries cleanly. If a URL is retried after a human reviews a login wall and authenticates, the new result replaces the old one rather than creating a duplicate entry.</p>
<h2 id="heading-module-7-the-main-loop">Module 7: The Main Loop</h2>
<p><code>index.py</code> wires everything together. It reads the URL list, loads state, skips already-audited URLs, and runs the audit loop.</p>
<pre><code class="language-python">import csv
import os
import sys
import time
import argparse

from state import load_state, is_audited, mark_audited, add_to_needs_human
from browser import fetch_page
from extractor import extract
from linkchecker import check_links
from hitl import should_pause, pause_reason, pause_and_prompt
from reporter import write_result, write_summary

INPUT_CSV = os.path.join(os.path.dirname(__file__), "input.csv")


def read_urls(path: str) -&gt; list[str]:
    with open(path, newline="", encoding="utf-8") as f:
        return [row["url"].strip() for row in csv.DictReader(f) if row.get("url", "").strip()]


def run(auto: bool = False):
    if not os.environ.get("ANTHROPIC_API_KEY"):
        print("Error: ANTHROPIC_API_KEY environment variable is not set.")
        sys.exit(1)

    urls = read_urls(INPUT_CSV)
    pending = [u for u in urls if not is_audited(u)]

    print(f"Starting audit: {len(pending)} pending, {len(urls) - len(pending)} already done.\n")

    total = len(urls)

    try:
        for i, url in enumerate(pending, start=1):
            position = urls.index(url) + 1
            print(f"[{position}/{total}] {url}", end=" -&gt; ", flush=True)

            # Browser navigation
            snapshot = fetch_page(url)

            # Human-in-the-loop check
            if should_pause(snapshot):
                reason = pause_reason(snapshot)

                if auto:
                    print(f"AUTO-SKIPPED ({reason})")
                    add_to_needs_human(url)
                    mark_audited(url)
                    continue

                action = pause_and_prompt(url, reason)
                if action == "quit":
                    print("Exiting.")
                    break
                elif action == "skip":
                    add_to_needs_human(url)
                    mark_audited(url)
                    continue
                # "retry" falls through to re-fetch below
                snapshot = fetch_page(url)

            # Claude extraction
            result = extract(snapshot)

            # Broken link check
            links = check_links(snapshot.get("raw_links", []), snapshot.get("final_url", url))
            result["broken_links"] = links

            # Write result immediately
            write_result(result)
            mark_audited(url)

            overall = "PASS" if all(
                result.get(f, {}).get("status") == "PASS"
                for f in ["title", "description", "h1", "canonical"]
            ) and links["status"] == "PASS" else "FAIL"

            print(overall)

    except KeyboardInterrupt:
        print("\n\nInterrupted. Progress saved. Re-run to continue.")
        return

    write_summary()
    passed = sum(
        1 for e in [r for r in []]
        if all(e.get(f, {}).get("status") == "PASS" for f in ["title", "description", "h1", "canonical"])
    )
    print(f"\nAudit complete. Report saved to report.json and report-summary.txt")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--auto", action="store_true", help="Auto-skip URLs requiring human review")
    args = parser.parse_args()
    run(auto=args.auto)
</code></pre>
<p>The <code>KeyboardInterrupt</code> handler is the resume mechanism. When you press Ctrl+C, the handler prints a message and exits cleanly. Because <code>mark_audited()</code> is called after <code>write_result()</code> for each URL, the next run skips everything already processed.</p>
<h2 id="heading-running-the-agent">Running the Agent</h2>
<p>Interactive mode (pauses on edge cases):</p>
<pre><code class="language-bash">python index.py
</code></pre>
<p>Auto mode (skips edge cases, adds to <code>needs_human[]</code>):</p>
<pre><code class="language-bash">python index.py --auto
</code></pre>
<p>When it runs, you'll see the browser window open for each URL and the terminal print progress:</p>
<pre><code class="language-plaintext">Starting audit: 7 pending, 0 already done.

[1/7] https://example.com -&gt; PASS
[2/7] https://example.com/about -&gt; FAIL
[3/7] https://example.com/contact -&gt; AUTO-SKIPPED (Unexpected status code: 404)
...
Audit complete. Report saved to report.json and report-summary.txt
</code></pre>
<p>To resume after an interruption:</p>
<pre><code class="language-bash">python index.py --auto
# Starting audit: 4 pending, 3 already done.
</code></pre>
<h2 id="heading-scheduling-for-agency-use">Scheduling for Agency Use</h2>
<p>For recurring weekly audits, create a batch file and schedule it with Windows Task Scheduler.</p>
<p>Create <code>run-audit.bat</code>:</p>
<pre><code class="language-batch">@echo off
set ANTHROPIC_API_KEY=your-key-here
cd /d C:\Users\yourname\Desktop\seo-agent
python index.py --auto
</code></pre>
<p>In Windows Task Scheduler:</p>
<ol>
<li><p>Create a new Basic Task</p>
</li>
<li><p>Set the trigger to Weekly, Monday at 7:00 AM</p>
</li>
<li><p>Set the action to "Start a program"</p>
</li>
<li><p>Browse to your <code>run-audit.bat</code> file</p>
</li>
</ol>
<p>Check <code>report-summary.txt</code> on Monday morning. URLs in <code>needs_human[]</code> in <code>state.json</code> need manual review — login walls, paywalls, or pages that returned unexpected status codes.</p>
<p>For macOS/Linux, use cron:</p>
<pre><code class="language-bash"># Run every Monday at 7am
0 7 * * 1 cd /path/to/seo-agent &amp;&amp; ANTHROPIC_API_KEY=your-key python index.py --auto
</code></pre>
<h2 id="heading-what-the-results-look-like">What the Results Look Like</h2>
<p>I ran this agent against seven of my own published pages across Hashnode, freeCodeCamp, and DEV.to. Every single one failed.</p>
<pre><code class="language-plaintext">https://hashnode.com/@dannwaneri                    | FAIL [h1]
https://freecodecamp.org/news/claude-code-skill     | FAIL [description]
https://freecodecamp.org/news/stop-letting-ai-guess | FAIL [description]
https://freecodecamp.org/news/rag-system-handbook   | FAIL [title, description]
https://freecodecamp.org/news/author/dannwaneri     | FAIL [description]
https://dev.to/dannwaneri/gatekeeping-panic         | FAIL [title]
https://dev.to/dannwaneri/production-rag-system     | FAIL [title]

0/7 URLs passed
</code></pre>
<p>The freeCodeCamp description issues are partly platform-level — freeCodeCamp's template sometimes truncates or omits meta descriptions for article listing pages. The DEV.to title issues are mine. Article titles that work as headlines often exceed 60 characters in the <code>&lt;title&gt;</code> tag.</p>
<p>A note on the 60-character title rule: this is a display threshold, not a ranking penalty. Google indexes titles of any length. The 60-character guideline reflects approximately how many characters fit in a desktop SERP result before truncation. Titles over 60 characters often still rank — they just get cut off in search results, which can hurt click-through rate. The agent flags display risk, not a ranking violation.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>The agent as built handles the core SEO audit workflow. Obvious extensions:</p>
<ul>
<li><p><strong>Performance metrics</strong> — add a Lighthouse or PageSpeed Insights API call per URL</p>
</li>
<li><p><strong>Structured data validation</strong> — check for JSON-LD schema markup and validate it</p>
</li>
<li><p><strong>Email delivery</strong> — send <code>report-summary.txt</code> via SMTP after the run completes</p>
</li>
<li><p><strong>Multi-client support</strong> — separate <code>input.csv</code> files per client, separate report directories</p>
</li>
</ul>
<p>The full code including all seven modules is at <a href="https://github.com/dannwaneri/seo-agent">dannwaneri/seo-agent</a>. Clone it, add your URLs, and run it.</p>
<p><em>If you found this useful, I write about practical AI agent setups for developers and agencies at</em> <a href="https://dev.to/dannwaneri"><em>DEV.to/@dannwaneri</em></a><em>. The DEV.to companion piece covers the design decisions behind the agent — why HITL matters, why Browser Use over scrapers, and what the audit results mean for your own published content.</em></p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
