<?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[ cloudflare - 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[ cloudflare - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 23 May 2026 22:20:11 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/cloudflare/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Deploy a Full-Stack Next.js App on Cloudflare Workers with GitHub Actions CI/CD ]]>
                </title>
                <description>
                    <![CDATA[ I typically build my projects using Next.js 14 (App Router) and Supabase for authentication along with Postgres. The default deployment choice for a Next.js app is usually Vercel, and for good reason: ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-deploy-a-full-stack-next-js-app-on-cloudflare-workers-with-github-actions-ci-cd/</link>
                <guid isPermaLink="false">69f2145e6e0124c05e1a5b6e</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md Tarikul Islam ]]>
                </dc:creator>
                <pubDate>Wed, 29 Apr 2026 14:23:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/cbb9e559-baa7-452c-992a-3416041712ad.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I typically build my projects using Next.js 14 (App Router) and Supabase for authentication along with Postgres. The default deployment choice for a Next.js app is usually Vercel, and for good reason: it provides an excellent developer experience.</p>
<p>But after running the same project on both platforms for about a week, I started exploring Cloudflare Workers as an alternative. I noticed improvements in latency (lower TTFB) and found the free tier to be more flexible for my use case.</p>
<p>Deploying Next.js apps on Cloudflare used to be challenging. Earlier solutions like Cloudflare Pages had limitations with full Next.js features, and tools like <code>next-on-pages</code> often lagged behind the latest releases.</p>
<p>That changed with the introduction of <a href="https://opennext.js.org/cloudflare"><code>@opennextjs/cloudflare</code></a>. It allows you to compile a standard Next.js application into a Cloudflare Worker, supporting features like SSR, ISR, middleware, and the Image component – all without requiring major code changes.</p>
<p>In this guide, I’ll walk you through the exact steps I used to deploy my full-stack Next.js + Supabase application to Cloudflare Workers.</p>
<p>This article is the runbook I wish I had when I started.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-choose-cloudflare-workers-over-vercel">Why Choose Cloudflare Workers Over Vercel?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-stack">The Stack</a></p>
</li>
<li><p><a href="#heading-step-1-install-the-cloudflare-adapter">Step 1 — Install the Cloudflare Adapter</a></p>
</li>
<li><p><a href="#heading-step-2-wire-opennext-into-next-dev">Step 2 — Wire OpenNext into next dev</a></p>
</li>
<li><p><a href="#heading-step-3-local-environment-setup-with-devvars">Step 3— Local Environment Setup with .dev.vars</a></p>
</li>
<li><p><a href="#heading-step-4-deploy-your-app-from-your-local-machine">Step 4 — Deploy Your App from Your Local Machine</a></p>
</li>
<li><p><a href="#heading-step-5-push-your-secrets-to-the-worker">Step 5 — Push your secrets to the Worker</a></p>
</li>
<li><p><a href="#heading-step-6-set-up-continuous-deployment-with-github-actions">Step 6 — Set Up Continuous Deployment with GitHub Actions</a></p>
</li>
<li><p><a href="#heading-step-7-updating-the-project-the-daily-workflow">Step 7 — Updating the project (the daily workflow)</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-choose-cloudflare-workers-over-vercel">Why Choose Cloudflare Workers Over Vercel?</h2>
<p>When deploying a Next.js application, Vercel is often the default choice. It offers a smooth developer experience and tight integration with Next.js.</p>
<p>But Cloudflare Workers provides a compelling alternative, especially when you care about global performance and cost efficiency.</p>
<p>Here’s a high-level comparison (at the time of writing):</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Vercel (Hobby)</th>
<th>Cloudflare Workers (Free Tier)</th>
</tr>
</thead>
<tbody><tr>
<td>Requests</td>
<td>Fair usage limits</td>
<td>Millions of requests per day</td>
</tr>
<tr>
<td>Cold starts</td>
<td>~100–300 ms (region-based)</td>
<td>Near-zero (V8 isolates)</td>
</tr>
<tr>
<td>Edge locations</td>
<td>Limited regions for SSR</td>
<td>300+ global edge locations</td>
</tr>
<tr>
<td>Bandwidth</td>
<td>~100 GB/month (soft cap)</td>
<td>Generous / no strict cap on free tier</td>
</tr>
<tr>
<td>Custom domains</td>
<td>Supported</td>
<td>Supported</td>
</tr>
<tr>
<td>Image optimization</td>
<td>Counts toward usage</td>
<td>Available via <code>IMAGES</code> binding</td>
</tr>
<tr>
<td>Pricing beyond free</td>
<td>Starts at ~$20/month</td>
<td>Low-cost, usage-based pricing</td>
</tr>
</tbody></table>
<h3 id="heading-key-takeaways">Key Takeaways</h3>
<ul>
<li><p><strong>Lower latency globally</strong>: Cloudflare runs your app across hundreds of edge locations, reducing response time for users worldwide.</p>
</li>
<li><p><strong>Minimal cold starts</strong>: Thanks to V8 isolates, functions start almost instantly.</p>
</li>
<li><p><strong>Cost efficiency</strong>: The free tier is generous enough for portfolios, blogs, and many small-to-medium apps.</p>
</li>
</ul>
<h3 id="heading-trade-offs-to-consider">Trade-offs to Consider</h3>
<p>Cloudflare Workers use a V8 isolate runtime, not a full Node.js environment. That means:</p>
<ul>
<li><p>Some Node.js APIs like <code>fs</code> or <code>child_process</code> aren't available</p>
</li>
<li><p>Native binaries or certain libraries may not work</p>
</li>
</ul>
<p>That said, for most modern stacks –&nbsp;like Next.js + Supabase + Stripe + Resend – this limitation is rarely an issue.</p>
<p>In short, choose <strong>Vercel</strong> if you want the simplest, plug-and-play Next.js deployment. Choose <strong>Cloudflare Workers</strong> if you want better edge performance and more flexible scaling.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before getting started, make sure you have the following set up. Most of these take only a few minutes:</p>
<ul>
<li><p><strong>Node.js 18+</strong> and <strong>pnpm 9+</strong> (you can also use npm or yarn, but this guide uses pnpm.)</p>
</li>
<li><p>A <strong>Cloudflare account</strong> 👉 <a href="https://dash.cloudflare.com/sign-up">https://dash.cloudflare.com/sign-up</a></p>
</li>
<li><p>A <strong>Supabase account</strong> (if your app uses a database) 👉 <a href="https://supabase.com">https://supabase.com</a></p>
</li>
<li><p>A <strong>GitHub repository</strong> for your project (required later for CI/CD setup)</p>
</li>
<li><p>A <strong>domain name</strong> (optional) – You’ll get a free <code>*.workers.dev</code> URL by default.</p>
</li>
</ul>
<h3 id="heading-install-wrangler-cloudflare-cli">Install Wrangler (Cloudflare CLI)</h3>
<p>We’ll use Wrangler to build and deploy the application:</p>
<pre><code class="language-bash">pnpm add -D wrangler
</code></pre>
<h2 id="heading-the-stack">The Stack</h2>
<p>Here’s the tech stack used in this project:</p>
<ul>
<li><p><strong>Next.js (v14.2.x):</strong> Using the App Router with Edge runtime for both public and dashboard routes</p>
</li>
<li><p><strong>Supabase:</strong> Handles authentication, Postgres database, and Row-Level Security (RLS)</p>
</li>
<li><p><strong>Tailwind CSS</strong> + UI utilities: For styling, along with lightweight animation using Framer Motion</p>
</li>
<li><p><strong>Cloudflare Workers:</strong> Deployment powered by <code>@opennextjs/cloudflare</code> and <code>wrangler</code></p>
</li>
<li><p><strong>GitHub Actions:</strong> Used to automate CI/CD and deployments</p>
</li>
</ul>
<p><strong>Note:</strong> If you're using Next.js <strong>15 or later</strong>, you can remove the<br><code>--dangerouslyUseUnsupportedNextVersion</code> flag from the build script, as it's only required for certain Next.js 14 setups.</p>
<h2 id="heading-step-1-install-the-cloudflare-adapter">Step 1 — Install the Cloudflare Adapter</h2>
<p>From inside your existing Next.js project, install the OpenNext adapter along with Wrangler (Cloudflare’s CLI tool):</p>
<pre><code class="language-bash">pnpm add @opennextjs/cloudflare
pnpm add -D wrangler
</code></pre>
<p>Then add the deploy scripts to <code>package.json</code>:</p>
<pre><code class="language-jsonc">{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",

    "cloudflare-build": "opennextjs-cloudflare build --dangerouslyUseUnsupportedNextVersion",
    "preview":          "pnpm cloudflare-build &amp;&amp; opennextjs-cloudflare preview",
    "deploy":           "pnpm cloudflare-build &amp;&amp; wrangler deploy",
    "upload":           "pnpm cloudflare-build &amp;&amp; opennextjs-cloudflare upload",
    "cf-typegen":       "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
  }
}
</code></pre>
<p>What each script does:</p>
<table>
<thead>
<tr>
<th>Script</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>pnpm cloudflare-build</code></td>
<td>Compiles your Next app into <code>.open-next/</code> (the Worker bundle). No upload.</td>
</tr>
<tr>
<td><code>pnpm preview</code></td>
<td>Builds and runs the Worker locally with <code>wrangler dev</code>. Closest thing to prod.</td>
</tr>
<tr>
<td><code>pnpm deploy</code></td>
<td>Builds and uploads to Cloudflare. <strong>This ships to production.</strong></td>
</tr>
<tr>
<td><code>pnpm upload</code></td>
<td>Builds and uploads a <em>new version</em> without promoting it (for staged rollouts).</td>
</tr>
<tr>
<td><code>pnpm cf-typegen</code></td>
<td>Regenerates <code>cloudflare-env.d.ts</code> types after editing <code>wrangler.jsonc</code>.</td>
</tr>
</tbody></table>
<p><strong>Heads up:</strong> the Pages-based <code>@cloudflare/next-on-pages</code> is a different tool. We are <strong>not</strong> using Pages — we're deploying as a real Worker. Don't mix the two.</p>
<h2 id="heading-step-2-wire-opennext-into-next-dev">Step 2 — Wire OpenNext into <code>next dev</code></h2>
<p>So that <code>pnpm dev</code> can read your Cloudflare bindings (env vars, R2, KV, D1, …) the same way production will, edit <code>next.config.mjs</code>:</p>
<pre><code class="language-js">/** @type {import('next').NextConfig} */
const nextConfig = {};

if (process.env.NODE_ENV !== "production") {
  const { initOpenNextCloudflareForDev } = await import(
    "@opennextjs/cloudflare"
  );
  initOpenNextCloudflareForDev();
}

export default nextConfig;
</code></pre>
<p>We only call it in development so <code>next build</code> stays fast and CI doesn't spin up a Miniflare instance for nothing.</p>
<h2 id="heading-step-3-local-environment-setup-with-devvars">Step 3 — Local Environment Setup with <code>.dev.vars</code></h2>
<p>When working with Cloudflare Workers locally, Wrangler uses a file called <code>.dev.vars</code> to store environment variables (instead of <code>.env.local</code> used by Next.js).</p>
<p>A simple and reliable approach is to keep an example file in your repo and ignore the real one.</p>
<h3 id="heading-example-devvarsexample-committed">Example: <code>.dev.vars.example</code> (committed)</h3>
<pre><code class="language-bash">NEXT_PUBLIC_SUPABASE_URL="https://YOUR-PROJECT-ref.supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="YOUR-ANON-KEY"
NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL="admin@example.com"
</code></pre>
<h3 id="heading-set-up-your-local-environment">Set Up Your Local Environment</h3>
<p>Run the following commands:</p>
<pre><code class="language-plaintext">cp .dev.vars.example .dev.vars
cp .dev.vars .env.local
</code></pre>
<ul>
<li><p><code>.dev.vars</code> is used by Wrangler (<code>wrangler dev</code>)</p>
</li>
<li><p><code>.env.local</code> is used by Next.js (<code>next dev</code>)</p>
</li>
</ul>
<h3 id="heading-why-use-both-files">Why Use Both Files?</h3>
<ul>
<li><p><code>next dev</code> reads from <code>.env.local</code></p>
</li>
<li><p><code>wrangler dev</code> (used in <code>pnpm preview</code>) reads from <code>.dev.vars</code></p>
</li>
</ul>
<p>Keeping both files in sync ensures your app behaves consistently in development and when running in the Cloudflare runtime.</p>
<h3 id="heading-update-gitignore">Update <code>.gitignore</code></h3>
<p>Make sure these files are ignored:</p>
<pre><code class="language-plaintext">.dev.vars
.env*.local
.open-next
.wrangler
</code></pre>
<h2 id="heading-step-4-deploy-your-app-from-your-local-machine">Step 4 — Deploy Your App from Your Local Machine</h2>
<p>Once <code>pnpm preview</code> is working correctly, you're ready to deploy your application:</p>
<pre><code class="language-bash">pnpm deploy
</code></pre>
<p>Under the hood that runs:</p>
<pre><code class="language-bash">pnpm cloudflare-build &amp;&amp; wrangler deploy
</code></pre>
<p>The first time, Wrangler will:</p>
<ol>
<li><p>Compile your app to <code>.open-next/worker.js</code>.</p>
</li>
<li><p>Upload the script + assets to Cloudflare.</p>
</li>
<li><p>Print your live URL, e.g. <code>https://porfolio.&lt;your-account&gt;.workers.dev</code>.</p>
</li>
</ol>
<p>Open it in a browser. Congratulations — you're on Cloudflare's edge in 330+ cities. The page should be served in <strong>&lt;100 ms</strong> TTFB from anywhere.  </p>
<p><a href="https://portfolio.tarikuldev.workers.dev/">Here's the live version of my own portfolio deployed this way</a></p>
<h2 id="heading-step-5-push-your-secrets-to-the-worker">Step 5 — Push Your Secrets to the Worker</h2>
<p>Local <code>.dev.vars</code> is <strong>not</strong> uploaded by <code>wrangler deploy</code>. You have to push secrets explicitly:</p>
<pre><code class="language-bash">wrangler secret put NEXT_PUBLIC_SUPABASE_URL
wrangler secret put NEXT_PUBLIC_SUPABASE_ANON_KEY
wrangler secret put NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL
</code></pre>
<p>Each command prompts you for the value and stores it encrypted on Cloudflare. Or do it visually:</p>
<blockquote>
<p>Cloudflare Dashboard → <strong>Workers &amp; Pages</strong> → your worker → <strong>Settings</strong> → <strong>Variables and Secrets</strong> → <strong>Add</strong>.</p>
</blockquote>
<p>Important: <code>NEXT_PUBLIC_*</code> vars are inlined into the client bundle at build time, so they also need to be available when pnpm cloudflare-build runs (locally, that's your .env.local; in CI, see Step 10).</p>
<h2 id="heading-step-6-set-up-continuous-deployment-with-github-actions">Step 6 — Set Up Continuous Deployment with GitHub Actions</h2>
<p>Once your local deployment is working, the next step is automating deployments so every push to the <code>main</code> branch updates production automatically.</p>
<p>With this workflow:</p>
<ul>
<li><p>Pull requests will run validation checks</p>
</li>
<li><p>Production deploys only happen after successful builds</p>
</li>
<li><p>Broken code never reaches your live site</p>
</li>
</ul>
<p>Create the following file inside your project:</p>
<p><code>.github/workflows/deploy.yml</code></p>
<pre><code class="language-yaml">name: CI / Deploy to Cloudflare Workers

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: cloudflare-deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  verify:
    name: Lint and Build
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm build
        env:
          NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
          NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
          NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL }}

  deploy:
    name: Deploy to Cloudflare Workers
    needs: verify
    if: github.event_name == 'push' &amp;&amp; github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Build and Deploy
        run: pnpm run deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
          NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
          NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL }}
</code></pre>
<h3 id="heading-required-github-repo-secrets">Required GitHub repo secrets</h3>
<p>Go to GitHub repo → Settings → Secrets and variables → Actions → New repository secret and add:</p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Where to get it</th>
</tr>
</thead>
<tbody><tr>
<td><code>CLOUDFLARE_API_TOKEN</code></td>
<td><a href="https://dash.cloudflare.com/profile/api-tokens">https://dash.cloudflare.com/profile/api-tokens</a> → "Edit Cloudflare Workers" template</td>
</tr>
<tr>
<td><code>CLOUDFLARE_ACCOUNT_ID</code></td>
<td>Cloudflare dashboard → right sidebar, "Account ID"</td>
</tr>
<tr>
<td><code>CLOUDFLARE_ACCOUNT_SUBDOMAIN</code></td>
<td>Your <code>*.workers.dev</code> subdomain (used only for the deployment URL link)</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_SUPABASE_URL</code></td>
<td>Supabase project settings</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_SUPABASE_ANON_KEY</code></td>
<td>Supabase project settings</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL</code></td>
<td>Email pre-filled on <code>/dashboard/login</code></td>
</tr>
</tbody></table>
<p>That's it. Push it to <code>main</code> and it'll go live in about 90 seconds. PRs run lint and build only, so broken code never reaches production.</p>
<h2 id="heading-step-7-updating-the-project-the-daily-workflow">Step 7 — Updating the Project (the Daily Workflow)</h2>
<p>After the initial setup, the loop is boringly simple — which is the whole point. Here's what I actually do day-to-day:</p>
<h3 id="heading-code-change">Code Change</h3>
<pre><code class="language-bash">git checkout -b feat/new-section
# ...edit files...
pnpm dev                # iterate locally
pnpm preview            # final smoke test on the Worker runtime
git commit -am "feat: add new section"
git push origin feat/new-section
</code></pre>
<p>Open a PR and the <strong>verify</strong> that the job runs. Then review, merge, and the deploy it. The job ships to Cloudflare automatically.</p>
<h3 id="heading-updating-env-vars-secrets">Updating env Vars / Secrets</h3>
<pre><code class="language-bash"># Local
nano .dev.vars

# Production
wrangler secret put NEXT_PUBLIC_SUPABASE_URL
# ...etc.
</code></pre>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>When I started this migration, I was nervous about leaving Vercel — the Next.js DX there is genuinely excellent. But the moment you push beyond a hobby site, Cloudflare's economics and edge performance are not close.</p>
<p>With <code>@opennextjs/cloudflare</code>, the developer experience has also caught up: my <code>pnpm dev</code> loop is identical, my <code>pnpm preview</code> mimics production, and <code>git push</code> deploys globally in ~90 seconds.</p>
<p>If you've been holding off because the old Cloudflare Pages + Next.js story was rough, that era is over. Try this runbook on a side project this weekend and see for yourself.</p>
<p>If you found this useful, the full repo is <a href="./">here</a> — feel free to clone it as a starter.</p>
<p>Happy shipping.</p>
<p>— <em>Tarikul</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Self-Learning RAG System with Knowledge Reflection ]]>
                </title>
                <description>
                    <![CDATA[ Every RAG system I've seen — including the one I wrote a handbook about on this site — has the same fundamental problem. It doesn't learn. You ingest 500 documents. You ask a question. The system retr ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-self-learning-rag-system-with-knowledge-reflection/</link>
                <guid isPermaLink="false">69ebd821b463d4844c4f97e5</guid>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ webdev ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Daniel Nwaneri ]]>
                </dc:creator>
                <pubDate>Fri, 24 Apr 2026 20:52:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/d4567606-0d92-434c-8fd1-6137549350cf.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every RAG system I've seen — including the one I wrote a handbook about on this site — has the same fundamental problem.</p>
<p>It doesn't learn.</p>
<p>You ingest 500 documents. You ask a question. The system retrieves the three most similar chunks and hands them to the LLM. Repeat for the next query.</p>
<p>The system knows exactly as much as it did on day one. It's a library that never builds a card catalog, never cross-references its own shelves, never notices that three of its books are saying contradictory things.</p>
<p>That's what I set out to fix with a knowledge reflection layer. After every ingest, the system finds semantically related documents already in the index and asks an LLM to synthesise what's new, how it connects, and what gap remains. That synthesis gets embedded, stored, and boosted in search results.</p>
<p>The knowledge base gets smarter as you add more documents — not just bigger.</p>
<p>This tutorial shows you exactly how to build it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-base-system">How to Set Up the Base System</a></p>
</li>
<li><p><a href="#heading-why-standard-rag-has-a-memory-problem">Why Standard RAG Has a Memory Problem</a></p>
</li>
<li><p><a href="#heading-step-1-schema-update">Step 1: Schema Update</a></p>
</li>
<li><p><a href="#heading-step-2-the-reflection-engine">Step 2: The Reflection Engine</a></p>
</li>
<li><p><a href="#heading-step-3-consolidation">Step 3: Consolidation</a></p>
</li>
<li><p><a href="#heading-step-4-wire-it-into-your-ingest-handler">Step 4: Wire It Into Your Ingest Handler</a></p>
</li>
<li><p><a href="#heading-step-5-boost-reflections-in-search">Step 5: Boost Reflections in Search</a></p>
</li>
<li><p><a href="#heading-step-6-filtering-by-doc_type">Step 6: Filtering by doc_type</a></p>
</li>
<li><p><a href="#heading-what-changes-after-you-build-this">What Changes After You Build This</a></p>
</li>
<li><p><a href="#heading-deploying">Deploying</a></p>
</li>
<li><p><a href="#heading-what-to-build-next">What to Build Next</a></p>
</li>
</ol>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>In this tutorial, you'll build a post-ingest reflection pipeline that:</p>
<ol>
<li><p>Fires automatically after every document ingest</p>
</li>
<li><p>Finds the most semantically related documents already in the index</p>
</li>
<li><p>Asks Kimi K2.5 to synthesise a three-sentence insight linking the new document to existing knowledge</p>
</li>
<li><p>Stores that reflection with <code>doc_type=reflection</code> and a 1.5× ranking boost in search results</p>
</li>
<li><p>Consolidates reflections into summaries every three ingests</p>
</li>
</ol>
<p>By the end, searching your knowledge base will surface both raw document chunks and reflection artifacts the system wrote on ingest.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You will need:</p>
<ul>
<li><p>A Cloudflare account — free tier works</p>
</li>
<li><p>Node.js v18+ and Wrangler CLI installed (<code>npm install -g wrangler</code>)</p>
</li>
<li><p>Basic TypeScript familiarity</p>
</li>
</ul>
<p>No external API keys. Everything runs on Cloudflare's infrastructure.</p>
<h2 id="heading-how-to-set-up-the-base-system">How to Set Up the Base System</h2>
<p>If you have already built the RAG system from my <a href="https://www.freecodecamp.org/news/build-a-production-rag-system-with-cloudflare-workers-handbook">freeCodeCamp handbook</a>, skip this section — your system is ready for the reflection layer.</p>
<p>If you're starting fresh, this section gets you to a working base in about 15 minutes.</p>
<h3 id="heading-scaffold-the-project">Scaffold the Project</h3>
<pre><code class="language-bash">npm create cloudflare@latest rag-reflection-system
cd rag-reflection-system
</code></pre>
<p>Choose: Hello World example → TypeScript → No deploy yet.</p>
<h3 id="heading-create-the-vectorize-index-and-d1-database">Create the Vectorize Index and D1 Database</h3>
<pre><code class="language-bash">npx wrangler vectorize create rag-index --dimensions=384 --metric=cosine
npx wrangler d1 create rag-db
</code></pre>
<h3 id="heading-configure-wranglertoml">Configure wrangler.toml</h3>
<pre><code class="language-toml">name = "rag-reflection-system"
main = "src/index.ts"
compatibility_date = "2026-01-01"

[[vectorize]]
binding = "VECTORIZE"
index_name = "rag-index"

[[d1_databases]]
binding = "DB"
database_name = "rag-db"
database_id = "YOUR_DB_ID"

[ai]
binding = "AI"
</code></pre>
<h3 id="heading-create-the-documents-table">Create the <code>documents</code> Table</h3>
<pre><code class="language-sql">-- migrations/001_init.sql
CREATE TABLE IF NOT EXISTS documents (
  id TEXT PRIMARY KEY,
  content TEXT NOT NULL,
  source TEXT,
  date_created TEXT DEFAULT (datetime('now'))
);
</code></pre>
<pre><code class="language-bash">npx wrangler d1 execute rag-db --remote --file=./migrations/001_init.sql
</code></pre>
<h3 id="heading-add-the-ingest-and-search-endpoints">Add the <code>ingest</code> and <code>search</code> endpoints</h3>
<p>Replace <code>src/index.ts</code> with this minimal working system:</p>
<pre><code class="language-typescript">export interface Env {
  VECTORIZE: VectorizeIndex;
  DB: D1Database;
  AI: Ai;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise&lt;Response&gt; {
    const url = new URL(request.url);

    if (url.pathname === '/ingest' &amp;&amp; request.method === 'POST') {
      const { id, content, source } = await request.json() as any;

      const embResult = await env.AI.run('@cf/baai/bge-small-en-v1.5', {
        text: [content.slice(0, 512)],
      }) as any;
      const vector = embResult.data[0];

      await env.VECTORIZE.upsert([{
        id,
        values: vector,
        metadata: { content: content.slice(0, 1000), source, doc_type: 'raw' },
      }]);

      await env.DB.prepare(
        'INSERT OR REPLACE INTO documents (id, content, source) VALUES (?, ?, ?)'
      ).bind(id, content, source ?? '').run();

      return Response.json({ success: true, id });
    }

    if (url.pathname === '/search' &amp;&amp; request.method === 'POST') {
      const { query } = await request.json() as any;

      const embResult = await env.AI.run('@cf/baai/bge-small-en-v1.5', {
        text: [query],
      }) as any;
      const vector = embResult.data[0];

      const results = await env.VECTORIZE.query(vector, {
        topK: 5,
        returnMetadata: 'all',
      });

      const context = results.matches
        .map(m =&gt; m.metadata?.content as string)
        .filter(Boolean)
        .join('\n\n');

      const answer = await env.AI.run('@cf/moonshotai/kimi-k2.5', {
        messages: [
          { role: 'system', content: 'Answer using only the context provided.' },
          { role: 'user', content: `Context:\n\({context}\n\nQuestion: \){query}` },
        ],
        max_tokens: 256,
      }) as any;

      return Response.json({ answer: answer.response, sources: results.matches.map(m =&gt; m.id) });
    }

    return new Response('RAG system running', { status: 200 });
  },
};
</code></pre>
<h3 id="heading-deploy-and-verify">Deploy and Verify</h3>
<pre><code class="language-bash">npx wrangler deploy
</code></pre>
<p>Test it:</p>
<pre><code class="language-bash"># Ingest a document
curl -X POST https://your-worker.workers.dev/ingest \
  -H "Content-Type: application/json" \
  -d '{"id": "doc-001", "content": "Cursor pagination beats offset pagination for live-updating datasets because offset becomes unreliable when rows are inserted or deleted during pagination."}'

# Search
curl -X POST https://your-worker.workers.dev/search \
  -H "Content-Type: application/json" \
  -d '{"query": "what pagination approach should I use?"}'
</code></pre>
<p>If you get a grounded answer back, the base system is working. The next sections add the reflection layer on top of this foundation.</p>
<h2 id="heading-why-standard-rag-has-a-memory-problem">Why Standard RAG Has a Memory Problem</h2>
<p>Standard RAG retrieval is stateless. Every query goes in cold. The system has no memory of what it found before, no synthesis of what it learned across documents, and no growing understanding of what questions remain unanswered.</p>
<p>Imagine you've ingested 200 documents about your product. Twelve of them touch on a pricing decision made last year. No single one has the full picture — it's distributed across quarterly reports, meeting notes, an internal Slack export, a few Notion pages.</p>
<p>A user asks: "Why did we change our pricing structure?"</p>
<p>Standard RAG retrieves the three most similar chunks. If those three chunks collectively have the answer, great. If they don't — if the real answer requires synthesising across those twelve documents — the system has no mechanism for that. It returns fragments. The LLM makes its best guess.</p>
<p>The reflection layer addresses this directly. When the twelfth pricing document gets ingested, the system finds the eleven related documents, synthesises what connects them, and stores that synthesis as a retrievable artifact. The answer to "why did we change our pricing structure" exists in the index before anyone asks the question.</p>
<p>Not smarter retrieval — smarter indexing.</p>
<h2 id="heading-step-1-schema-update">Step 1: Schema Update</h2>
<p>The reflection layer needs two new fields in your D1 documents table. Run this migration:</p>
<pre><code class="language-sql">-- migrations/003_add_reflection_fields.sql
ALTER TABLE documents ADD COLUMN doc_type TEXT DEFAULT 'raw';
ALTER TABLE documents ADD COLUMN reflection_score REAL DEFAULT 0;
ALTER TABLE documents ADD COLUMN parent_reflection_id TEXT;
</code></pre>
<p>Apply it:</p>
<pre><code class="language-bash">wrangler d1 execute mcp-knowledge-db --remote --file=./migrations/003_add_reflection_fields.sql
</code></pre>
<p><code>doc_type</code> distinguishes raw documents (<code>raw</code>), single-document reflections (<code>reflection</code>), and consolidated multi-reflection summaries (<code>summary</code>). You'll use this field to filter — exposing only reflections to users who want the distilled view, or excluding them for users who want raw source chunks.</p>
<h2 id="heading-step-2-the-reflection-engine">Step 2: The Reflection Engine</h2>
<p>Create <code>src/engines/reflection.ts</code>. This is the core of the layer.</p>
<pre><code class="language-typescript">import { Env } from '../types/env';
import { resolveEmbeddingModel, resolveReflectionModel } from '../config/models';

const REFLECTION_BOOST = 1.5;
const CONSOLIDATION_THRESHOLD = 3; // consolidate every N new reflections

export async function reflect(
  newDocId: string,
  newDocContent: string,
  env: Env
): Promise&lt;void&gt; {
  // 1. Find semantically related documents already in the index
  const embModel = resolveEmbeddingModel(env.EMBEDDING_MODEL);
  const embResult = await env.AI.run(embModel.id as any, {
    text: [newDocContent.slice(0, 512)],
  });
  const queryVector = (embResult as any).data?.[0];
  if (!queryVector) return;

  const related = await env.VECTORIZE.query(queryVector, {
    topK: 5,
    filter: { doc_type: { $eq: 'raw' } },
    returnMetadata: 'all',
  });

  const relatedDocs = (related.matches ?? []).filter(
    m =&gt; m.id !== newDocId &amp;&amp; (m.score ?? 0) &gt; 0.65
  );

  if (relatedDocs.length === 0) return; // nothing related yet — skip

  // 2. Build synthesis prompt
  const relatedSummaries = relatedDocs
    .slice(0, 3)
    .map((m, i) =&gt; `Document \({i + 1}: \){String(m.metadata?.content ?? '').slice(0, 300)}`)
    .join('\n\n');

  const prompt = `You are synthesising knowledge across documents in a knowledge base.

New document:
${newDocContent.slice(0, 600)}

Related existing documents:
${relatedSummaries}

Write exactly three sentences:
1. What the new document adds that the existing documents don't already cover
2. How the new document connects to or extends the existing documents
3. What gap or question remains unanswered across all these documents

Be specific. Reference actual content. Do not summarise — synthesise.`;

  // 3. Call the reflection model
  const reflModel = resolveReflectionModel(env.REFLECTION_MODEL);
  const llmResp = await env.AI.run(reflModel.id as any, {
    messages: [{ role: 'user', content: prompt }],
    max_tokens: 180,
  });

  const reflectionText = (llmResp as any)?.response?.trim();
  if (!reflectionText || reflectionText.length &lt; 40) return;

  // 4. Embed and store the reflection
  const reflEmbResult = await env.AI.run(embModel.id as any, {
    text: [reflectionText],
  });
  const reflVector = (reflEmbResult as any).data?.[0];
  if (!reflVector) return;

  const reflectionId = `refl_\({newDocId}_\){Date.now()}`;

  await env.VECTORIZE.upsert([
    {
      id: reflectionId,
      values: reflVector,
      metadata: {
        content: reflectionText,
        doc_type: 'reflection',
        parent_id: newDocId,
        reflection_score: REFLECTION_BOOST,
        source_doc_ids: relatedDocs.map(m =&gt; m.id).join(','),
        date_created: new Date().toISOString(),
      },
    },
  ]);

  await env.DB.prepare(
    `INSERT INTO documents
     (id, content, doc_type, reflection_score, parent_id, date_created)
     VALUES (?, ?, 'reflection', ?, ?, ?)`
  )
    .bind(reflectionId, reflectionText, REFLECTION_BOOST, newDocId, new Date().toISOString())
    .run();

  // 5. Check if consolidation is due
  const recentCount = await env.DB
    .prepare(`SELECT COUNT(*) as cnt FROM documents WHERE doc_type = 'reflection' AND date_created &gt; datetime('now', '-1 hour')`)
    .first&lt;{ cnt: number }&gt;();

  if ((recentCount?.cnt ?? 0) &gt;= CONSOLIDATION_THRESHOLD) {
    await consolidate(env);
  }
}
</code></pre>
<p>Two things worth noting here.</p>
<p>First, the semantic threshold (<code>score &gt; 0.65</code>) matters. Too low and you're synthesising unrelated documents. Too high and you're rarely finding connections. 0.65 works well with <code>bge-small</code>. You can bump it to 0.72 with <code>qwen3-0.6b</code> (1024d) where scores cluster higher.</p>
<p>The prompt structure is deliberate. Three sentences, each doing a specific job: what's new, how it connects, what remains. This keeps reflections useful for retrieval. A freeform synthesis prompt produces beautiful prose that doesn't retrieve well. This structure produces retrievable artifacts.</p>
<h2 id="heading-step-3-consolidation">Step 3: Consolidation</h2>
<p>As reflections accumulate, they need their own synthesis layer — otherwise you're adding noise at a higher abstraction level.</p>
<p>Add this to <code>src/engines/reflection.ts</code>:</p>
<pre><code class="language-typescript">export async function consolidate(env: Env): Promise&lt;void&gt; {
  // Fetch recent reflections not yet consolidated
  const recent = await env.DB
    .prepare(
      `SELECT id, content FROM documents
       WHERE doc_type = 'reflection'
       AND id NOT IN (
         SELECT DISTINCT parent_id FROM documents
         WHERE doc_type = 'summary' AND parent_id IS NOT NULL
       )
       ORDER BY date_created DESC
       LIMIT 6`
    )
    .all&lt;{ id: string; content: string }&gt;();

  if (!recent.results || recent.results.length &lt; CONSOLIDATION_THRESHOLD) return;

  const reflectionTexts = recent.results.map((r, i) =&gt; `Reflection \({i + 1}: \){r.content}`).join('\n\n');

  const prompt = `You are consolidating multiple knowledge reflections into a single compressed insight.

${reflectionTexts}

Write two to three sentences that capture the most important cross-cutting pattern or tension across these reflections. What does the knowledge base now understand that it didn't before these documents were added? What's the most important open question?

Be precise. No preamble.`;

  const reflModel = resolveReflectionModel(env.REFLECTION_MODEL);
  const llmResp = await env.AI.run(reflModel.id as any, {
    messages: [{ role: 'user', content: prompt }],
    max_tokens: 320,
  });

  const summaryText = (llmResp as any)?.response?.trim();
  if (!summaryText || summaryText.length &lt; 40) return;

  const embModel = resolveEmbeddingModel(env.EMBEDDING_MODEL);
  const embResult = await env.AI.run(embModel.id as any, { text: [summaryText] });
  const summaryVector = (embResult as any).data?.[0];
  if (!summaryVector) return;

  const summaryId = `summary_${Date.now()}`;

  await env.VECTORIZE.upsert([
    {
      id: summaryId,
      values: summaryVector,
      metadata: {
        content: summaryText,
        doc_type: 'summary',
        reflection_score: REFLECTION_BOOST * 1.2,
        source_reflection_ids: recent.results.map(r =&gt; r.id).join(','),
        date_created: new Date().toISOString(),
      },
    },
  ]);

  await env.DB.prepare(
    `INSERT INTO documents (id, content, doc_type, reflection_score, date_created)
     VALUES (?, ?, 'summary', ?, ?)`
  )
    .bind(summaryId, summaryText, REFLECTION_BOOST * 1.2, new Date().toISOString())
    .run();
}
</code></pre>
<p>Summaries get a 1.2× multiplier on top of the base reflection boost. In search results, a summary synthesising twelve related documents should rank above any single document chunk on broad conceptual queries. On specific factual queries, the raw chunks will score higher. The ranking sorts itself.</p>
<h2 id="heading-step-4-wire-it-into-your-ingest-handler">Step 4: Wire It Into Your Ingest Handler</h2>
<p>The reflection runs as a background job. It doesn't block the ingest response — that would add 2–3 seconds to every ingest call.</p>
<p>In your <code>src/handlers/ingest.ts</code>, after you've stored the document:</p>
<pre><code class="language-typescript">import { reflect } from '../engines/reflection';

// ... existing ingest logic ...

// After VECTORIZE.upsert() and DB insert succeed:
ctx.waitUntil(
  reflect(documentId, content, env).catch(err =&gt; {
    console.warn('[reflection] failed for', documentId, err.message);
  })
);

return new Response(JSON.stringify({
  success: true,
  documentId,
  chunks: chunkCount,
  // ... rest of response
}), { headers: { 'Content-Type': 'application/json' } });
</code></pre>
<p><code>ctx.waitUntil()</code> is the Cloudflare Workers primitive for background work. The response returns immediately. The reflection runs after. The ingest API stays fast.</p>
<p>The <code>.catch()</code> is important. A failed reflection should never fail an ingest. Raw documents are the source of truth. Reflections are derived value — useful, but not critical path.</p>
<h2 id="heading-step-5-boost-reflections-in-search">Step 5: Boost Reflections in Search</h2>
<p>Add the reflection boost to your ranking logic in <code>src/engines/hybrid.ts</code>. After RRF fusion and before returning results:</p>
<pre><code class="language-typescript">// Apply reflection boost
const boosted = results.map(r =&gt; ({
  ...r,
  score: r.doc_type === 'reflection' || r.doc_type === 'summary'
    ? r.score * (r.reflection_score ?? 1.5)
    : r.score,
}));

return boosted.sort((a, b) =&gt; b.score - a.score);
</code></pre>
<p>This is a post-fusion boost, not a pre-fusion rerank. The reasoning: apply RRF across all results first, so reflections earn their place on raw relevance before getting boosted. A reflection that would not rank in the top 20 on raw similarity shouldn't appear just because it has a boost multiplier.</p>
<h2 id="heading-step-6-filtering-by-doctype">Step 6: Filtering by <code>doc_type</code></h2>
<p>Your search endpoint should accept a <code>doc_type</code> filter so callers can control what they see:</p>
<pre><code class="language-typescript">// In your search request handler:
const docTypeFilter = body.filters?.doc_type;

// Pass to Vectorize query:
const vectorFilter: Record&lt;string, unknown&gt; = {};
if (docTypeFilter) {
  vectorFilter.doc_type = docTypeFilter;
}
</code></pre>
<p>This gives callers three modes:</p>
<pre><code class="language-bash"># Only reflections and summaries
POST /search
{ "query": "pricing decisions", "filters": { "doc_type": { "$in": ["reflection", "summary"] } } }

# Only source documents
POST /search
{ "query": "pricing decisions", "filters": { "doc_type": { "$eq": "raw" } } }

# Default: all types, reflections boosted
POST /search
{ "query": "pricing decisions" }
</code></pre>
<p>The default (no filter) is the most useful. Let the boost do its job. Restrict to raw when you need citations. Restrict to reflections when you want the synthesised view.</p>
<h2 id="heading-what-changes-after-you-build-this">What Changes After You Build This</h2>
<p>At 200 documents, the difference becomes noticeable. Queries that previously returned five fragmented chunks now surface a reflection that already synthesised those chunks. Broad conceptual queries — "what do we know about X?" — start returning genuinely useful summaries instead of just the most-similar individual paragraph.</p>
<p>At 2,000 documents, the reflection layer is the most valuable part of the system. The raw chunks answer specific factual questions. The reflections and summaries answer conceptual questions that could not be answered from any single document. The system has learned something no individual document contains.</p>
<p>One failure mode worth knowing: if your embedding model has poor semantic clustering — old <code>bge-small</code> at 384d with mixed-domain documents — the related-documents retrieval step will surface weak connections and produce shallow reflections. The 0.65 threshold filters most of this out, but if you're seeing reflections that seem off-topic, your embeddings are the first thing to check.</p>
<h2 id="heading-deploying">Deploying</h2>
<pre><code class="language-bash">wrangler d1 execute mcp-knowledge-db --remote --file=./migrations/003_add_reflection_fields.sql
wrangler deploy
</code></pre>
<p>Then ingest a few documents and watch what happens:</p>
<pre><code class="language-bash"># Ingest document 1
curl -X POST https://your-worker.workers.dev/ingest \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"id": "doc-001", "content": "Your document text here..."}'

# After a few seconds, check if a reflection was created
curl "https://your-worker.workers.dev/search" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "your topic", "filters": {"doc_type": {"$eq": "reflection"}}}'
</code></pre>
<p>Reflections won't appear until there are related documents to synthesise. Ingest at least three documents on similar topics before expecting to see them.</p>
<h2 id="heading-what-to-build-next">What to Build Next</h2>
<p>The reflection layer as described here fires after every ingest. That's expensive at high ingest volume: if you're batch-importing 10,000 documents, you don't want 10,000 individual reflection calls.</p>
<p>For bulk ingestion, gate it: call <code>reflect()</code> only when a document's similarity search returns a match above 0.8, or batch-run reflection after the bulk import completes. The <code>POST /ingest/batch</code> endpoint in the <a href="https://github.com/dannwaneri/vectorize-mcp-worker">full repo</a> does this.</p>
<p>The second thing worth building: surfacing reflections in your UI with a visual distinction. A search result that's a reflection should look different from a raw chunk. In the dashboard included in the repo, reflections render with a <code>💡</code> badge and a "synthesised from N documents" note.</p>
<p>Full source at <a href="https://github.com/dannwaneri/vectorize-mcp-worker">github.com/dannwaneri/vectorize-mcp-worker</a> — reflection engine, consolidation, batch ingest, dashboard, OpenAPI spec.</p>
<p>The codebase is TypeScript, deploys with a single <code>wrangler deploy</code>, runs for roughly $1–5/month at 10,000 queries/day.</p>
<p>Standard RAG retrieves. This learns.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Headless WordPress Frontend with Astro SSR on Cloudflare Pages ]]>
                </title>
                <description>
                    <![CDATA[ This tutorial shows you how to run WordPress as a headless CMS with an Astro frontend deployed to Cloudflare Pages. For a project I was recently working on, the requirement was to use WordPress as the ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-headless-wordpress-frontend-with-astro-ssr-on-cloudflare-pages/</link>
                <guid isPermaLink="false">69e65d6ec9501dd0100e2105</guid>
                
                    <category>
                        <![CDATA[ WordPress ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Astro ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ headless cms ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tech With RJ ]]>
                </dc:creator>
                <pubDate>Mon, 20 Apr 2026 17:07:58 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/87087639-b2d1-4641-a2c9-f8f369b49406.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This tutorial shows you how to run WordPress as a headless CMS with an Astro frontend deployed to Cloudflare Pages.</p>
<p>For a project I was recently working on, the requirement was to use WordPress as the site's backend. Content management, blog posts, and media were all handled through the WordPress admin. The frontend was open: it could be a theme, a template, or something customized through Elementor.</p>
<p>I could've built the same result in Elementor, but the process would've been slower and harder to maintain. Drag-and-drop works until the design gets specific, and then every small tweak costs more time than it should.</p>
<p>As a full stack developer, writing code turned out to be faster for me and produced cleaner output. Tools like Claude Code make the iteration cycle even tighter. So I kept the requirement –&nbsp;WordPress as the backend – and decided to build the frontend separately in code.</p>
<p>I wanted to share how I did this so that, if you're facing similar requirements, you'll know the way forward.</p>
<p>By the end of this tutorial, you'll have:</p>
<ul>
<li><p>A WordPress install serving content through its REST API on a subdomain</p>
</li>
<li><p>An Astro SSR frontend rendering the content on the root domain</p>
</li>
<li><p>A Cloudflare Pages deployment triggered on every git push</p>
</li>
<li><p>Security hardening for a headless WordPress setup</p>
</li>
<li><p>Draft post preview working across both systems</p>
</li>
</ul>
<p><strong>Prerequisites:</strong> You should be comfortable with the command line, have basic familiarity with WordPress admin, and know enough JavaScript to read and write simple functions.</p>
<p>To follow along, you'll need a WordPress installation, a GitHub account, and a Cloudflare account.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-headless-wordpress">Why Headless WordPress?</a></p>
</li>
<li><p><a href="#heading-the-architecture">The Architecture</a></p>
</li>
<li><p><a href="#heading-why-astro">Why Astro?</a></p>
</li>
<li><p><a href="#heading-infrastructure-setup">Infrastructure Setup</a></p>
<ul>
<li><p><a href="#heading-step-1-move-dns-to-cloudflare">Step 1: Move DNS to Cloudflare</a></p>
</li>
<li><p><a href="#heading-step-2-create-the-cms-subdomain">Step 2: Create the CMS Subdomain</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-wordpress-configuration">WordPress Configuration</a></p>
<ul>
<li><p><a href="#heading-tell-wordpress-it-lives-on-the-subdomain">Tell WordPress it Lives on the Subdomain</a></p>
</li>
<li><p><a href="#heading-must-use-plugin-redirect-and-preview">Must-Use Plugin: Redirect and Preview</a></p>
</li>
<li><p><a href="#heading-clean-up-plugins">Clean Up Plugins</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-astro-frontend">The Astro Frontend</a></p>
<ul>
<li><p><a href="#heading-astroconfigmjs">astro.config.mjs</a></p>
</li>
<li><p><a href="#heading-env">.env</a></p>
</li>
<li><p><a href="#heading-srclibwordpressjs">src/lib/wordpress.js</a></p>
</li>
<li><p><a href="#heading-srcmiddlewarejs">src/middleware.js</a></p>
</li>
<li><p><a href="#heading-srclayoutslayoutastro">src/layouts/Layout.astro</a></p>
</li>
<li><p><a href="#heading-srcpagesblogindexastro">src/pages/blog/index.astro</a></p>
</li>
<li><p><a href="#heading-srcpagesblogslugastro">src/pages/blog/[slug].astro</a></p>
</li>
<li><p><a href="#heading-srcpagessitemapxmlts">src/pages/sitemap.xml.ts</a></p>
</li>
<li><p><a href="#heading-srcstylesglobalcss">src/styles/global.css</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-cicd-with-cloudflare-pages">CI/CD with Cloudflare Pages</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
<li><p><a href="#heading-good-to-know">Good to Know</a></p>
</li>
</ul>
<h2 id="heading-why-headless-wordpress">Why Headless WordPress?</h2>
<p>Headless WordPress separates content management from content delivery. WordPress keeps doing what it handles well: storing content and giving editors a familiar admin interface. A separate frontend handles rendering, routing, and performance.</p>
<p>A few situations where this split pays off:</p>
<ul>
<li><p>Your content team is trained on WordPress and moving them elsewhere would slow everyone down. Headless preserves their workflow and gives you a modern frontend.</p>
</li>
<li><p>Your site needs a design or interaction pattern that a WordPress theme or page builder struggles to deliver. Custom dashboards, interactive tools, data-driven layouts, or integrations with non-WordPress APIs all fit here.</p>
</li>
<li><p>You want edge delivery and modern tooling without rebuilding content management from scratch. WordPress handles content and media well. A JavaScript frontend on a CDN handles delivery well. Headless lets each side do its job.</p>
</li>
<li><p>You need the same content across multiple surfaces. One WordPress install feeds a marketing site, a mobile app, and an internal dashboard through the same REST API.</p>
</li>
</ul>
<p>Headless is not a fit for every site. Skip it if your site is a simple brochure, if one person does everything in the admin, or if you have no developer time to maintain a second codebase. A regular WordPress theme is the better answer there.</p>
<h2 id="heading-the-architecture">The Architecture</h2>
<p>The term "headless" means you strip WordPress of its frontend responsibility. Instead of WordPress generating and serving HTML pages to visitors, it only stores and serves content through its REST API. A separate frontend framework, in this case Astro, handles what the visitor actually sees.</p>
<img src="https://cdn.hashnode.com/uploads/covers/605584805f8d5121697263ca/0508174b-0740-47cf-a780-943a0c254ae1.png" alt="Diagram of a headless WordPress setup with Cloudflare Pages and Astro SSR fetching content via the WordPress REST API, with GitHub auto-deploy and a CMS subdomain for editors." style="display:block;margin:0 auto" width="7093" height="1017" loading="lazy">

<p>When a visitor loads a page, the request hits Cloudflare Pages, which runs the Astro server. Astro fetches the relevant content from WordPress via the REST API, builds the HTML, and returns it to the visitor. WordPress never touches the visitor's browser.</p>
<p>Content editors log into the WordPress admin at the CMS subdomain. They write, publish, and manage content as they normally would. The moment they publish, the content is live. There's no rebuild step because Astro fetches fresh data on every request.</p>
<p>The REST API has been built into WordPress since version 4.7. You don't need a GraphQL plugin, a paid headless CMS service, or any extra infrastructure.</p>
<h2 id="heading-why-astro">Why Astro?</h2>
<p>You could use Next.js, Nuxt, or SvelteKit here as well. But I chose Astro because its defaults fit this use case.</p>
<p>Astro compiles components to plain HTML and ships zero JavaScript to the browser by default. You only add client-side JavaScript where you explicitly need it.</p>
<p>For a CMS-driven site, most pages need none. SSR mode means every request fetches fresh data from WordPress at runtime, so content changes go live immediately without a rebuild. Cloudflare has an official adapter that handles the build output. Tailwind v4 integrates through a Vite plugin with no config file needed.</p>
<p>If WordPress wasn't a requirement, I would have used Next.js with Payload CMS. Payload gives you a fully typed CMS built in TypeScript that sits inside the same Next.js project, with more control over your content schema from day one. But the requirement was WordPress, and for a WordPress REST API frontend, Astro is the faster and cleaner choice.</p>
<h2 id="heading-infrastructure-setup">Infrastructure Setup</h2>
<p>Here's my setup: domain at Namecheap, WordPress on Hostinger shared hosting, and a Google Workspace email. The steps below apply to any host, whether shared hosting with cPanel or hPanel, a VPS with Apache or Nginx, or a self-managed server.</p>
<h3 id="heading-step-1-move-dns-to-cloudflare">Step 1: Move DNS to Cloudflare</h3>
<p>First, you'll need to move your domain's nameservers to Cloudflare. This gives you free DDoS protection, SSL, and the ability to attach a custom domain to Cloudflare Pages.</p>
<p>Before switching, verify that all DNS records transferred correctly, including your website A or CNAME records. For email, get your MX, SPF, DKIM, and DMARC values from your email provider's admin panel and add them to Cloudflare DNS first, otherwise email breaks during propagation.</p>
<h3 id="heading-step-2-create-the-cms-subdomain">Step 2: Create the CMS Subdomain</h3>
<p>Move WordPress to <code>cms.yourdomain.com</code> so the root domain is free for Astro. In Cloudflare DNS, add an A record pointing <code>cms</code> at your server IP, or a CNAME if your host uses a CDN hostname. Then create the subdomain in your hosting panel pointing to the same WordPress directory.</p>
<p>One thing people miss: your server needs its own SSL certificate for the connection between Cloudflare and your origin to work. Cloudflare handles SSL at its edge, but if the origin has no certificate, you get a 525 error.</p>
<p>On Hostinger, this isn't automatic for new subdomains. Install it manually through hPanel. On cPanel, use Let's Encrypt. On a VPS, use Certbot.</p>
<p>Moving WordPress off the root domain also means <code>/wp-admin</code> no longer exists at your main domain, which reduces exposure. But the default login path is still <code>/wp-admin</code> on the subdomain. That is the first thing you should change — more on this in the Good to Know section at the end.</p>
<h2 id="heading-wordpress-configuration">WordPress Configuration</h2>
<h3 id="heading-tell-wordpress-it-lives-on-the-subdomain">Tell WordPress it Lives on the Subdomain</h3>
<p>In <code>wp-config.php</code>, before the "That's all, stop editing!" comment:</p>
<pre><code class="language-php">define('WP_HOME',    'https://cms.yourdomain.com');
define('WP_SITEURL', 'https://cms.yourdomain.com');
</code></pre>
<p>WordPress admin is now at <code>cms.yourdomain.com/wp-admin</code>. The old path at the root domain stops working. That's intentional.</p>
<h3 id="heading-must-use-plugin-redirect-and-preview">Must-Use Plugin: Redirect and Preview</h3>
<p>WordPress has a folder called <code>mu-plugins</code> inside <code>wp-content</code>. Files placed there are treated as must-use plugins. They load automatically on every request, before regular plugins, and there is no way to activate or deactivate them through the admin UI. This makes them the right place for behaviour you never want accidentally turned off.</p>
<p>Create <code>wp-content/mu-plugins/headless-redirect.php</code>:</p>
<pre><code class="language-php">&lt;?php
/*
Plugin Name: Headless Redirect
Description: Redirects frontend visitors to the Astro site and rewires the WordPress preview link.
*/

add_action('template_redirect', function() {
    if (is_user_logged_in()) return;
    if ($_SERVER['HTTP_HOST'] === 'cms.yourdomain.com') {
        wp_redirect('https://yourdomain.com', 302);
        exit;
    }
});

add_filter('preview_post_link', function(\(link, \)post) {
    $token = HEADLESS_PREVIEW_SECRET;
    \(type  = \)post-&gt;post_type;
    return 'https://yourdomain.com/preview?type=' . \(type . '&amp;id=' . \)post-&gt;ID . '&amp;token=' . $token;
}, 10, 2);
</code></pre>
<p>The <code>template_redirect</code> action fires when WordPress is about to render a page. If the visitor isn't logged in and the request is on the CMS subdomain, it redirects them to the main frontend. Logged-in editors pass through to the admin normally. REST API requests to <code>/wp-json/...</code> don't go through <code>template_redirect</code> at all, so they are unaffected.</p>
<p>The <code>preview_post_link</code> filter changes what happens when an editor clicks Preview on a draft post. By default, WordPress previews using its own theme, which in a headless setup renders blank.</p>
<p>This filter replaces that URL with a request to your Astro <code>/preview</code> page, passing the post ID, post type, and a secret token. Your Astro preview page uses those values to fetch the draft via the REST API and renders it exactly as it would appear live.</p>
<h3 id="heading-clean-up-plugins">Clean Up Plugins</h3>
<p>Now it's time to remove everything that renders the frontend: page builders, caching plugins, and hosting onboarding plugins.</p>
<p>But you'll want to keep Akismet, Wordfence, and Yoast SEO. Yoast adds SEO meta and Open Graph data directly to the REST API response, which your Astro pages read through <code>post.yoast_head_json</code>.</p>
<p>Then switch the active theme to a lightweight default. WordPress requires one active, but nobody sees it.</p>
<h2 id="heading-the-astro-frontend">The Astro Frontend</h2>
<p>Start with <code>pnpm create astro@latest</code>, then install the Cloudflare adapter and Tailwind:</p>
<pre><code class="language-bash">pnpm add @astrojs/cloudflare
pnpm add -D @tailwindcss/vite tailwindcss
</code></pre>
<h3 id="heading-astroconfigmjs">astro.config.mjs</h3>
<pre><code class="language-js">import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  output: 'server',
  adapter: cloudflare({ imageService: 'passthrough' }),
  vite: { plugins: [tailwindcss()] },
})
</code></pre>
<p><code>output: 'server'</code> puts Astro into full SSR mode. Without it, Astro pre-renders pages at build time, which breaks dynamic routes like <code>/blog/[slug]</code> that depend on WordPress content that didn't exist at build time.</p>
<p><code>imageService: 'passthrough'</code> is required specifically for Cloudflare Workers. Astro's default image service uses Sharp, which depends on <code>child_process</code> and <code>fs</code>. Those Node.js built-ins don't exist in the Cloudflare Workers runtime. The deployment fails with a module resolution error. Setting passthrough skips image processing entirely and renders standard <code>&lt;img&gt;</code> tags instead.</p>
<h3 id="heading-env">.env</h3>
<pre><code class="language-bash">WORDPRESS_API_URL=https://cms.yourdomain.com
</code></pre>
<p>Add this same variable in Cloudflare Pages project settings under Environment Variables before deploying.</p>
<h3 id="heading-srclibwordpressjs">src/lib/wordpress.js</h3>
<p>This file is the single place all WordPress API calls go through. Centralising them means if the API URL or authentication changes, you update one file.</p>
<p>The <code>_embed</code> parameter is important. By default, a post response only includes the post data. Featured images, author details, and categories are separate entities with their own IDs. Without <code>_embed</code>, you would need additional API requests to fetch each one. Adding it inlines all that related data into the same response.</p>
<p><code>cache: 'no-store'</code> on every fetch call is not optional. Cloudflare Workers runs a fetch cache internally that's separate from HTTP <code>Cache-Control</code> headers. Without disabling it, Cloudflare caches your WordPress API responses at the edge. An editor publishes a post and sees the old version on the frontend because the cached response is being served.</p>
<pre><code class="language-js">const WP_URL = import.meta.env.WORDPRESS_API_URL

const fetchWP = (path) =&gt;
  fetch(`\({WP_URL}\){path}`, { cache: 'no-store' }).then((r) =&gt; r.json())

export const getPosts = (page = 1, perPage = 10) =&gt;
  fetchWP(`/wp-json/wp/v2/posts?_embed&amp;per_page=\({perPage}&amp;page=\){page}`)

export const getPostBySlug = async (slug) =&gt; {
  const posts = await fetchWP(`/wp-json/wp/v2/posts?_embed&amp;slug=${slug}`)
  return posts[0]
}

export const getCategories = () =&gt;
  fetchWP(`/wp-json/wp/v2/categories`)

export const getPostsByCategory = (categoryId, page = 1) =&gt;
  fetchWP(`/wp-json/wp/v2/posts?_embed&amp;categories=\({categoryId}&amp;page=\){page}`)

export const getAllPostsForSitemap = () =&gt;
  fetchWP(`/wp-json/wp/v2/posts?_fields=slug,modified&amp;per_page=100`)
</code></pre>
<p>The sitemap function uses <code>_fields</code> instead of <code>_embed</code> to fetch only the fields it needs, keeping that request lightweight.</p>
<h3 id="heading-srcmiddlewarejs">src/middleware.js</h3>
<p>Middleware runs on every request before the page handler. This one adds <code>Cache-Control: no-store</code> to every SSR response so Cloudflare doesn't cache the rendered HTML pages.</p>
<pre><code class="language-js">export function onRequest(_context, next) {
  return next().then(response =&gt; {
    const newResponse = new Response(response.body, response)
    newResponse.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate')
    newResponse.headers.set('CDN-Cache-Control', 'no-store')
    return newResponse
  })
}
</code></pre>
<p>The original Response from Astro has immutable headers, so you can't call <code>.headers.set()</code> on it directly. The fix is to construct a new Response using the original body and response as the init argument. The new Response has mutable headers, so <code>.set()</code> works. <code>CDN-Cache-Control</code> is a Cloudflare-specific header that controls caching at the edge independently from the standard <code>Cache-Control</code> header.</p>
<h3 id="heading-srclayoutslayoutastro">src/layouts/Layout.astro</h3>
<p>Every page goes through this layout. HTML structure, meta tags, and global imports live here so you don't repeat them on every page.</p>
<pre><code class="language-astro">---
interface Props {
  title: string
  description?: string
}
const { title, description = '' } = Astro.props
---
&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;{title}&lt;/title&gt;
    &lt;meta name="description" content={description} /&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;slot name="nav" /&gt;
    &lt;main id="main-content"&gt;&lt;slot /&gt;&lt;/main&gt;
    &lt;slot name="footer" /&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Named slots let the navbar and footer sit outside <code>&lt;main&gt;</code>, keeping the HTML landmark structure correct for accessibility.</p>
<h3 id="heading-srcpagesblogindexastro">src/pages/blog/index.astro</h3>
<pre><code class="language-astro">---
import Layout from '../../layouts/Layout.astro'
import { getPosts, getCategories, getPostsByCategory } from '../../lib/wordpress'

const page = Number(Astro.url.searchParams.get('page') ?? 1)
const categoryId = Astro.url.searchParams.get('category')

const [posts, categories] = await Promise.all([
  categoryId ? getPostsByCategory(categoryId, page) : getPosts(page, 10),
  getCategories(),
])
---
&lt;Layout title="Blog"&gt;
  &lt;nav&gt;
    &lt;a href="/blog"&gt;All&lt;/a&gt;
    {categories.map((cat) =&gt; (
      &lt;a href={`/blog?category=${cat.id}`}&gt;{cat.name}&lt;/a&gt;
    ))}
  &lt;/nav&gt;

  &lt;ul&gt;
    {posts.map((post) =&gt; {
      const image   = post._embedded?.['wp:featuredmedia']?.[0]?.source_url
      const imageAlt = post._embedded?.['wp:featuredmedia']?.[0]?.alt_text ?? ''
      return (
        &lt;li&gt;
          {image &amp;&amp; &lt;img src={image} alt={imageAlt} /&gt;}
          &lt;a href={`/blog/${post.slug}`} set:html={post.title.rendered} /&gt;
          &lt;div set:html={post.excerpt.rendered} /&gt;
        &lt;/li&gt;
      )
    })}
  &lt;/ul&gt;

  {page &gt; 1 &amp;&amp; &lt;a href={`/blog?page=${page - 1}`}&gt;Previous&lt;/a&gt;}
  &lt;a href={`/blog?page=${page + 1}`}&gt;Next&lt;/a&gt;
&lt;/Layout&gt;
</code></pre>
<p><code>Promise.all</code> fetches posts and categories in parallel. The category filter reads from the URL query string so the same page handles both <code>/blog</code> and <code>/blog?category=5</code> without separate routes.</p>
<p>Featured images live inside <code>post._embedded['wp:featuredmedia'][0]</code> because <code>_embed</code> inlines the media object into the post response.</p>
<h3 id="heading-srcpagesblogslugastro">src/pages/blog/[slug].astro</h3>
<pre><code class="language-astro">---
import Layout from '../../layouts/Layout.astro'
import { getPostBySlug } from '../../lib/wordpress'

const { slug } = Astro.params
const post = await getPostBySlug(slug)
if (!post) return Astro.redirect('/404')

const image    = post._embedded?.['wp:featuredmedia']?.[0]?.source_url
const imageAlt = post._embedded?.['wp:featuredmedia']?.[0]?.alt_text ?? ''
const author   = post._embedded?.author?.[0]?.name
const seoTitle = post.yoast_head_json?.title ?? post.title.rendered
const seoDesc  = post.yoast_head_json?.og_description ?? ''
---
&lt;Layout title={seoTitle} description={seoDesc}&gt;
  &lt;article&gt;
    &lt;h1 set:html={post.title.rendered} /&gt;
    &lt;p&gt;{author} · {new Date(post.date).toLocaleDateString()}&lt;/p&gt;
    {image &amp;&amp; &lt;img src={image} alt={imageAlt} /&gt;}
    &lt;div set:html={post.content.rendered} /&gt;
  &lt;/article&gt;
&lt;/Layout&gt;
</code></pre>
<p>Use <code>set:html</code> for WordPress content, not <code>{post.content.rendered}</code>. Astro treats curly brace expressions as text and escapes the HTML, so you see raw tags printed on the page instead of rendered content.</p>
<p>Always guard with <code>if (!post) return Astro.redirect('/404')</code>. If someone visits a slug that doesn't exist, the API returns an empty array. Without the guard, accessing properties on <code>undefined</code> throws an error that crashes the Cloudflare Worker and returns a 500.</p>
<p><code>post.yoast_head_json</code> is available when Yoast SEO is active. It contains the computed SEO title and description that Yoast generates. Using it means the SEO work done in WordPress carries over to the Astro frontend automatically.</p>
<h3 id="heading-srcpagessitemapxmlts">src/pages/sitemap.xml.ts</h3>
<pre><code class="language-ts">import type { APIRoute } from 'astro'
import { getAllPostsForSitemap } from '../lib/wordpress'

export const GET: APIRoute = async () =&gt; {
  const posts = await getAllPostsForSitemap()

  const urls = [
    { loc: 'https://yourdomain.com/', lastmod: new Date().toISOString() },
    { loc: 'https://yourdomain.com/blog/', lastmod: new Date().toISOString() },
    ...posts.map((p) =&gt; ({
      loc: `https://yourdomain.com/blog/${p.slug}/`,
      lastmod: p.modified,
    })),
  ]

  const xml = `&lt;?xml version="1.0" encoding="UTF-8"?&gt;
&lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&gt;
\({urls.map((u) =&gt; `  &lt;url&gt;\n    &lt;loc&gt;\){u.loc}&lt;/loc&gt;\n    &lt;lastmod&gt;${u.lastmod}&lt;/lastmod&gt;\n  &lt;/url&gt;`).join('\n')}
&lt;/urlset&gt;`

  return new Response(xml, { headers: { 'Content-Type': 'application/xml' } })
}
</code></pre>
<p>This generates fresh XML on every request, so the sitemap always reflects currently published posts without a rebuild.</p>
<h3 id="heading-srcstylesglobalcss">src/styles/global.css</h3>
<pre><code class="language-css">@import "tailwindcss";

@theme {
  --color-brand: #your-color;
  --font-sans: 'Your Font', sans-serif;
}
</code></pre>
<p>Tailwind v4 uses CSS-first configuration through the <code>@theme</code> block. CSS variables defined here become Tailwind utilities automatically. <code>--color-brand</code> becomes <code>bg-brand</code>, <code>text-brand</code>, and so on. No <code>tailwind.config.js</code> needed.</p>
<h2 id="heading-cicd-with-cloudflare-pages">CI/CD with Cloudflare Pages</h2>
<p>With the Astro code in place, the last piece is getting it deployed. Cloudflare Pages connects directly to GitHub, so you don't have to maintain a separate pipeline.</p>
<p>Here are the steps:</p>
<ol>
<li><p>Push your repo to GitHub.</p>
</li>
<li><p>Go to Cloudflare Pages, create a project, connect it to your GitHub repository.</p>
</li>
<li><p>Set the build command to <code>pnpm build</code> and the output directory to <code>dist</code>.</p>
</li>
<li><p>Under Environment Variables, add <code>WORDPRESS_API_URL</code> pointing to <code>https://cms.yourdomain.com</code>.</p>
</li>
<li><p>Deploy.</p>
</li>
</ol>
<p>After the first deploy, every push to <code>main</code> triggers a new deployment automatically. Cloudflare runs the build, and within minutes the new version is live globally. Content updates in WordPress go live immediately, since Astro fetches from WordPress on every request. A developer pushing code and an editor publishing a post are completely independent operations.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>This setup exists because of the specific requirement that the content team was already on WordPress and changing that was not on the table.</p>
<p>If you're starting fresh with no CMS in place, this is probably not the stack you want. Go with something like Next.js and Payload CMS where the backend and frontend are designed to work together from the start.</p>
<p>But if your situation matches where content editors are already familiar with WordPress, and you need a custom frontend that a page builder can't deliver cleanly, then this separation makes sense.</p>
<p>Pros:</p>
<ul>
<li><p>Content editors keep using WordPress. No retraining, no migration.</p>
</li>
<li><p>The frontend has full control over design and behaviour. No theme or plugin constraints.</p>
</li>
<li><p>Deployments are automatic on every push. Content changes go live immediately without a rebuild.</p>
</li>
<li><p>No added cost for most sites. WordPress stays on its existing host. Cloudflare Pages is free within generous limits, and scales to $5 per month on the Workers Paid plan if you outgrow them.</p>
</li>
</ul>
<p>Cons:</p>
<ul>
<li><p>Two systems to maintain instead of one. You operate the WordPress install (updates, plugins, backups) and maintain the Astro codebase separately.</p>
</li>
<li><p>The WordPress REST API has limitations. Complex content structures or real-time features need more work to handle compared to a purpose-built headless CMS.</p>
</li>
<li><p>Adapter and deployment target are tied together. @astrojs/cloudflare v13 drops Pages support in favor of Workers, so staying on Pages means staying on v12. Details in the Good to Know section.</p>
</li>
<li><p>Frontend changes require a developer. With Elementor, anyone with admin access could adjust layouts directly in the browser. Here, any visual change outside of content goes through code, which means it goes through you.</p>
</li>
</ul>
<p>The stack is WordPress on existing hosting, Astro on Cloudflare Pages, with GitHub as the bridge between development and production. It solves a specific problem cleanly. Outside of that problem, there are better options.</p>
<h2 id="heading-good-to-know">Good to Know</h2>
<p><strong>Change the default login URL immediately.</strong> Every bot targets <code>/wp-login.php</code> and <code>/wp-admin</code>. Install WPS Hide Login and move it to something custom. Anyone hitting the default paths gets a 404.</p>
<p><strong>Remove the</strong> <code>/wp-json/wp/v2/users</code> <strong>endpoint.</strong> It returns a public list of usernames. In headless mode you get author data through <code>_embed</code> and have no use for this endpoint. Add to the mu-plugin:</p>
<pre><code class="language-php">add_filter('rest_endpoints', function($endpoints) {
    unset($endpoints['/wp/v2/users']);
    unset($endpoints['/wp/v2/users/(?P&lt;id&gt;[\d]+)']);
    return $endpoints;
});
</code></pre>
<p><strong>Disable XML-RPC and enable 2FA.</strong> Add <code>add_filter('xmlrpc_enabled', '__return_false')</code> to the mu-plugin — you aren't using it in headless mode and it's a common brute force target. Enable Wordfence's Brute Force Protection and add two-factor authentication through WP 2FA for all admin accounts.</p>
<p><strong>Don't upgrade</strong> <code>@astrojs/cloudflare</code> <strong>to v13 if you deploy via Cloudflare Pages git-push CI.</strong> v12 outputs <code>dist/_worker.js</code> which Pages CI expects. v13 outputs a different format for <code>wrangler deploy</code> — Pages CI falls back to serving the <code>dist</code> folder as a static site and every SSR route returns 404 with no helpful error message.</p>
<p><strong>The v12 adapter throws a deprecation warning on</strong> <code>entrypointResolution</code><strong>.</strong> Silence it by adding <code>entrypointResolution: 'auto'</code> to the adapter options. Test before committing — it changes how the build locates the Worker entry file.</p>
<p><strong>Custom Post Types follow the same pattern.</strong> Register the CPT with <code>show_in_rest: true</code> and a <code>rest_base</code>, and it shows up at <code>/wp-json/wp/v2/your-base</code>. The same fetch helpers, <code>_embed</code>, and slug routing work exactly the same way.</p>
<p><strong>The REST API returns pagination headers.</strong> The raw response includes <code>X-WP-Total</code> and <code>X-WP-TotalPages</code> headers before you call <code>.json()</code>. If you want proper previous/next pagination, read those instead of guessing whether a next page exists.</p>
<p><strong>Wrap API calls in try/catch.</strong> If WordPress is unreachable, an unhandled fetch throws and returns a 500. A try/catch returns an empty page instead, which is a much better failure mode.</p>
<p><strong>Preview auth uses Application Passwords.</strong> WordPress 5.6 added Application Passwords under Users → Profile. That's what <code>WP_APP_USER</code> and <code>WP_APP_PASSWORD</code> in your <code>.env</code> should point to — not your regular admin password. Generate one per environment. Define the preview token as a constant in <code>wp-config.php</code> (<code>define('HEADLESS_PREVIEW_SECRET', '...')</code>) and reference that constant in the mu-plugin — never hardcode secrets in version-controlled files.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production RAG System with Cloudflare Workers – a Handbook for Devs ]]>
                </title>
                <description>
                    <![CDATA[ Most RAG tutorials show you a working demo and call it done. You copy the code, it runs locally, and then you try to put it in production and everything falls apart. This tutorial is different. I run  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-production-rag-system-with-cloudflare-workers-handbook/</link>
                <guid isPermaLink="false">69bb2fa98c55d6eefb6ce907</guid>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ webdev ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Daniel Nwaneri ]]>
                </dc:creator>
                <pubDate>Wed, 18 Mar 2026 23:05:13 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/cc3556fb-abe6-4aea-b9bd-83404319c1b9.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most RAG tutorials show you a working demo and call it done. You copy the code, it runs locally, and then you try to put it in production and everything falls apart.</p>
<p>This tutorial is different. I run a production RAG system (<a href="https://github.com/dannwaneri/vectorize-mcp-worker">vectorize-mcp-worker</a>) that handles real traffic at a total cost of \(5/month. The alternatives I evaluated ranged from \)100–$200/month. The difference isn't magic. It's architecture.</p>
<p>Here, you'll build <code>rag-tutorial-simple</code>: a clean, minimal RAG chatbot deployed on Cloudflare Workers. No external API keys. No paid vector database subscriptions. No servers to manage. Just Cloudflare's free tier – Workers, Vectorize, and Workers AI – doing the heavy lifting at the edge.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-rag-works">How RAG Works</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-your-project">How to Set Up Your Project</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-data-pipeline">How to Build the Data Pipeline</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-query-pipeline">How to Build the Query Pipeline</a></p>
</li>
<li><p><a href="#heading-how-to-add-error-handling-and-security">How to Add Error Handling and Security</a></p>
</li>
<li><p><a href="#heading-performance-and-cost-analysis">Performance and Cost Analysis</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-what-you-will-build">What You Will Build</h2>
<p>By the end of this tutorial, you'll have a globally deployed RAG API that:</p>
<ul>
<li><p>Accepts a natural language question via HTTP</p>
</li>
<li><p>Converts it to a vector embedding using Workers AI</p>
</li>
<li><p>Searches a knowledge base stored in Cloudflare Vectorize</p>
</li>
<li><p>Passes the retrieved context to an LLM (also on Workers AI) to generate an answer</p>
</li>
<li><p>Returns a grounded, accurate response (not a hallucination)</p>
</li>
</ul>
<p>The complete source code is available at <a href="https://github.com/dannwaneri/rag-tutorial-simple">github.com/dannwaneri/rag-tutorial-simple</a>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This is an intermediate-level tutorial. You should be comfortable with:</p>
<ul>
<li><p><strong>JavaScript/TypeScript</strong>: async/await, promises, basic types</p>
</li>
<li><p><strong>HTTP APIs</strong>: REST, request/response, JSON</p>
</li>
<li><p><strong>Command line basics</strong>: running npm commands, navigating directories</p>
</li>
</ul>
<p>You will need:</p>
<ul>
<li><p><strong>Node.js 18 or higher</strong>: check with <code>node --version</code></p>
</li>
<li><p><strong>A Cloudflare account</strong>: free tier is fine, sign up at <a href="https://dash.cloudflare.com/sign-up">cloudflare.com</a></p>
</li>
<li><p><strong>A code editor</strong>: VS Code recommended for TypeScript support</p>
</li>
</ul>
<p>That's it. No OpenAI key. No credit card for embeddings. Let's build.</p>
<h2 id="heading-how-rag-works">How RAG Works</h2>
<p>Before you write any code, you'll need a clear mental model of what you're building. This section explains the three core components of a RAG system, how data flows between them, and why this architecture works at scale.</p>
<h3 id="heading-the-mental-model">The Mental Model</h3>
<p>Think of a traditional LLM like a doctor who studied medicine for years but has been in a remote cabin with no internet since their graduation day. They are brilliant, but they only know what they knew when they left. Ask them about a drug approved last year and they'll either say they don't know or – worse – confidently give you wrong information.</p>
<p>RAG gives that doctor access to an up-to-date medical library. Before answering your question, they can look up the relevant pages, read them, and use that information to give you an accurate answer. Their training still matters (that is, they know how to read and interpret the information), but they're no longer limited to what they memorized years ago.</p>
<p>In technical terms, RAG works in three steps on every request:</p>
<ol>
<li><p><strong>Retrieve</strong>: find the most relevant documents from your knowledge base</p>
</li>
<li><p><strong>Augment</strong>: add those documents to the LLM prompt as context</p>
</li>
<li><p><strong>Generate</strong>: let the LLM produce an answer using both its training and the retrieved context</p>
</li>
</ol>
<h3 id="heading-the-three-components">The Three Components</h3>
<p>Every RAG system has three moving parts. Understanding each one will help you debug problems and make better architectural decisions as you build.</p>
<h4 id="heading-the-embedding-model">The Embedding Model</h4>
<p>An embedding model converts text into a vector – an array of numbers that represents the meaning of that text. The model you will use in this tutorial, <code>@cf/baai/bge-base-en-v1.5</code>, outputs 768 numbers for any piece of text you give it.</p>
<p>The critical property of embeddings is that semantically similar text produces numerically similar vectors. "How do I install Node.js?" and "What's the process for setting up Node?" will produce vectors that are close together. "How do I install Node.js?" and "What is the capital of France?" will produce vectors that are far apart.</p>
<p>This is what makes semantic search possible. You aren't matching keywords, you're matching meaning.</p>
<p>One rule you must never break: your documents and your queries must be embedded with the same model. If you embed your documents with <code>bge-base-en-v1.5</code> and your queries with a different model, the vectors won't be comparable and your searches will return garbage.</p>
<h4 id="heading-the-vector-database">The Vector Database</h4>
<p>The vector database stores your embeddings and lets you search them by similarity. In this tutorial, you'll use Cloudflare Vectorize.</p>
<p>When you run a similarity search, you pass in a query vector and Vectorize returns the K most similar vectors it has stored, along with their metadata and similarity scores. This is called approximate nearest neighbor search, and Vectorize is optimized to do it fast even across millions of vectors.</p>
<p>The key advantage of using Vectorize over an external vector database like Pinecone is co-location. Vectorize runs in the same Cloudflare network as your Worker. There's no external API call, no authentication roundtrip, and no network latency between your application and your database.</p>
<h4 id="heading-the-language-model">The Language Model</h4>
<p>The LLM is responsible for one thing: reading the retrieved context and generating a natural language answer. It doesn't search anything. It doesn't decide what's relevant. It just reads what you give it and writes a response.</p>
<p>This separation of concerns is intentional. The LLM is good at language: understanding questions, synthesizing information, writing clearly. The vector database is good at retrieval: finding relevant documents fast. RAG combines their strengths without asking either component to do something it is not designed for.</p>
<p>In this tutorial you'll use <code>@cf/meta/llama-3.3-70b-instruct-fp8-fast</code> through Workers AI. No API key required.</p>
<h3 id="heading-a-note-on-visual-embeddings">A Note on Visual Embeddings</h3>
<p>If you plan to extend this system to search images, you may be tempted to use a vision-language model like CLIP to generate visual embeddings (vectors that represent the image itself rather than a text description of it). This sounds clever but works worse for RAG in practice.</p>
<p>Visual embeddings match pixel similarity. They are good for "find images that look like this one." They are poor for "find the login screen" or "find dashboards showing error rates" because those queries are about meaning, not pixels.</p>
<p>The better approach – used in production – is to pass the image through a multimodal model like Llama 4 Scout, which generates a detailed text description and extracts visible text via OCR. You then embed that description using the same BGE model as your other documents.</p>
<p>The result lives in one unified index, works with your existing query pipeline, and produces better search results than visual embeddings for RAG use cases.</p>
<p>Cloudflare Workers AI does not support CLIP anyway. But even if it did, descriptions would outperform it for semantic search.</p>
<h3 id="heading-how-a-query-flows-through-the-system">How a Query Flows Through the System</h3>
<p>Here is exactly what happens when a user sends the question "What is RAG?" to your finished Worker:</p>
<ol>
<li><p><strong>Step 1 – Embed the question (20-30ms)</strong>: Your Worker calls Workers AI with the question text. The embedding model returns a 768-dimensional vector representing the meaning of the question.</p>
</li>
<li><p><strong>Step 2 – Search Vectorize (30-50ms)</strong>: Your Worker passes that vector to Vectorize, which searches your knowledge base and returns the 3 most similar documents with their similarity scores.</p>
</li>
<li><p><strong>Step 3 – Filter and build context (&lt; 1ms)</strong>: Documents with a similarity score below 0.5 are discarded. The remaining document texts are joined into a context string.</p>
</li>
<li><p><strong>Step 4 – Generate the answer (500-1500ms)</strong>: Your Worker sends the context and the question to the LLM. The LLM reads the context and generates a grounded answer.</p>
</li>
<li><p><strong>Step 5 – Return to the user</strong>: The answer and source metadata are returned as JSON.</p>
</li>
</ol>
<p>Total time: typically 600-1600ms end to end. The LLM generation step dominates. Everything else is fast.</p>
<h3 id="heading-why-this-works-at-scale">Why This Works at Scale</h3>
<p>A common objection to Cloudflare RAG is that it cannot meet sub-200ms retrieval requirements. That objection comes from a specific architectural mistake: trying to run the entire RAG pipeline, including heavy embedding generation and reranking, inside a single synchronous request. That's the wrong architecture.</p>
<p>The architecture you're building in this tutorial separates the loading step (which is slow and runs once) from the query step (which is fast and runs on every request). By the time a user asks a question, your documents are already embedded and stored. The query pipeline only needs to embed the question, run one vector search, and call the LLM. Those three steps are fast.</p>
<p>My production system (<a href="https://github.com/dannwaneri/vectorize-mcp-worker">vectorize-mcp-worker</a>) runs this architecture and handles real traffic at $5/month. The <a href="https://dev.to/dannwaneri/i-built-a-production-rag-system-for-5month-most-alternatives-cost-100-200-21hj">full performance breakdown is here</a>. Cloudflare RAG works. You just have to build it correctly.</p>
<h2 id="heading-how-to-set-up-your-project">How to Set Up Your Project</h2>
<p>In this section, you'll scaffold a Cloudflare Worker, create a Vectorize index to store your embeddings, and configure the bindings that connect them together.</p>
<h3 id="heading-how-to-create-the-project">How to Create the Project</h3>
<p>Open your terminal and create a new directory for the project.</p>
<p>On Mac/Linux:</p>
<pre><code class="language-bash">mkdir rag-tutorial-simple &amp;&amp; cd rag-tutorial-simple
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">mkdir rag-tutorial-simple
cd rag-tutorial-simple
</code></pre>
<p>Then run the Cloudflare scaffolding tool:</p>
<pre><code class="language-bash">npm create cloudflare@latest
</code></pre>
<p>Answer the prompts like this:</p>
<ul>
<li><p><strong>Directory/app name</strong>: <code>rag-tutorial-simple</code></p>
</li>
<li><p><strong>What would you like to start with?</strong> Hello World example</p>
</li>
<li><p><strong>TypeScript?</strong> Yes</p>
</li>
<li><p><strong>Deploy?</strong> No</p>
</li>
</ul>
<p>When it finishes, you'll have a working TypeScript Worker with Wrangler already configured.</p>
<h3 id="heading-how-to-create-the-vectorize-index">How to Create the Vectorize Index</h3>
<p>Vectorize is Cloudflare's vector database. It lives in the same network as your Worker, which means no external API call and no added latency when you search it.</p>
<pre><code class="language-bash">npx wrangler vectorize create rag-tutorial-index --dimensions=768 --metric=cosine
</code></pre>
<p>Two things to note here.</p>
<p><code>--dimensions=768</code> tells Vectorize how many numbers make up each embedding. This must match the output of the embedding model you use. The model you will use (<code>@cf/baai/bge-base-en-v1.5</code>) outputs 768 dimensions. If this number doesn't match, your searches will fail.</p>
<p><code>--metric=cosine</code> is how Vectorize measures similarity between vectors. Cosine similarity measures the angle between two vectors rather than the distance between them. For text embeddings, this captures semantic meaning more accurately than other metrics.</p>
<h3 id="heading-how-to-configure-wranglertoml">How to Configure wrangler.toml</h3>
<p>Open <code>wrangler.toml</code> and replace its contents with the following:</p>
<pre><code class="language-toml">name = "rag-tutorial-simple"
main = "src/index.ts"
compatibility_date = "2026-02-25"

[[vectorize]]
binding = "VECTORIZE"
index_name = "rag-tutorial-index"

[ai]
binding = "AI"
</code></pre>
<p>The <code>[[vectorize]]</code> block connects your Worker to the index you just created. The <code>[ai]</code> block gives your Worker access to Workers AI – both for generating embeddings and for running the language model that produces answers.</p>
<p>Notice that there are no API keys anywhere. Cloudflare handles authentication internally because everything – your Worker, Vectorize, and Workers AI – runs under the same account.</p>
<h3 id="heading-how-to-update-srcindexts">How to Update src/index.ts</h3>
<p>Open <code>src/index.ts</code> and replace the generated code with this:</p>
<pre><code class="language-typescript">export interface Env {
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  LOAD_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env): Promise&lt;Response&gt; {
    return new Response("RAG tutorial worker is running", { status: 200 });
  },
};
</code></pre>
<p>The <code>Env</code> interface tells TypeScript what bindings are available inside your Worker. <code>VectorizeIndex</code> and <code>Ai</code> are types provided by Cloudflare's type definitions.</p>
<h3 id="heading-how-to-verify-your-setup">How to Verify Your Setup</h3>
<p>Start the local development server:</p>
<pre><code class="language-bash">npx wrangler dev
</code></pre>
<p>Open your browser and visit <code>http://localhost:8787</code>. You should see:</p>
<pre><code class="language-plaintext">RAG tutorial worker is running
</code></pre>
<p>You will see two warnings in your terminal. Both are expected.</p>
<p>The first warning says that Vectorize doesn't support local mode. This means Vectorize queries won't work during local development unless you run with the <code>--remote</code> flag. You'll do this later when testing the full pipeline.</p>
<p>The second warning says the AI binding always accesses remote resources. This means that embedding generation and LLM calls always hit Cloudflare's servers, even in local development. This is fine: usage within the free tier limits costs nothing.</p>
<p>Your project structure at this point:</p>
<pre><code class="language-plaintext">rag-tutorial-simple/
├── scripts/
│   └── knowledge-base.ts
├── src/
│   └── index.ts
├── wrangler.toml
├── package.json
└── tsconfig.json
</code></pre>
<h2 id="heading-how-to-build-the-data-pipeline">How to Build the Data Pipeline</h2>
<p>The data pipeline is responsible for two things: generating embeddings for each document in your knowledge base, and storing those embeddings in Vectorize. You'll handle both steps inside the Worker itself using a <code>/load</code> endpoint.</p>
<p>This approach has a key advantage: you don't need an API token, an Account ID, or any external tooling. Everything uses the bindings you already configured in <code>wrangler.toml</code>.</p>
<h3 id="heading-how-to-create-the-knowledge-base">How to Create the Knowledge Base</h3>
<p>Create a <code>scripts/</code> folder in your project and add a file called <code>knowledge-base.ts</code>:</p>
<pre><code class="language-bash">mkdir scripts
</code></pre>
<p>Add your documents to <code>scripts/knowledge-base.ts</code>:</p>
<pre><code class="language-typescript">export const documents = [
  {
    id: "1",
    text: "Cloudflare Workers run JavaScript at the edge, in over 300 data centers worldwide. Requests are handled close to the user, reducing latency significantly compared to a single-region server.",
    metadata: { source: "cloudflare-docs", category: "workers" },
  },
  {
    id: "2",
    text: "Vectorize is Cloudflare's vector database. It stores embeddings and lets you search them by semantic similarity. It runs in the same network as your Worker, so there is no external API call needed.",
    metadata: { source: "cloudflare-docs", category: "vectorize" },
  },
  {
    id: "3",
    text: "Workers AI lets you run machine learning models directly on Cloudflare's infrastructure. You can generate embeddings and run LLM inference without leaving the Cloudflare network.",
    metadata: { source: "cloudflare-docs", category: "workers-ai" },
  },
  {
    id: "4",
    text: "RAG stands for Retrieval Augmented Generation. Instead of relying only on what the LLM was trained on, RAG retrieves relevant context from a knowledge base and adds it to the prompt before generating an answer.",
    metadata: { source: "ai-concepts", category: "rag" },
  },
  {
    id: "5",
    text: "An embedding is a numerical representation of text. Similar pieces of text produce similar embeddings. This is what makes semantic search possible — you search by meaning, not exact keywords.",
    metadata: { source: "ai-concepts", category: "embeddings" },
  },
  {
    id: "6",
    text: "The BGE model (bge-base-en-v1.5) is available through Workers AI. It generates 768-dimensional embeddings and works well for English semantic search tasks.",
    metadata: { source: "cloudflare-docs", category: "workers-ai" },
  },
  {
    id: "7",
    text: "Cosine similarity measures the angle between two vectors. For text embeddings, it captures semantic similarity regardless of text length, which makes it more reliable than Euclidean distance.",
    metadata: { source: "ai-concepts", category: "embeddings" },
  },
  {
    id: "8",
    text: "Cloudflare Workers have a free tier that includes 100,000 requests per day. Vectorize is available on both the Workers Free and Paid plans. The free tier lets you prototype and experiment. The Workers Paid plan starts at $5/month and includes higher usage allocations for production workloads.",
    metadata: { source: "cloudflare-docs", category: "pricing" },
  },
];
</code></pre>
<p>Each document has three fields. The <code>id</code> is a unique string that Vectorize uses to identify the vector. The <code>text</code> is what gets converted into an embedding. The <code>metadata</code> is stored alongside the vector and returned in search results. You'll use it later to display the source of each answer.</p>
<h3 id="heading-understanding-embeddings">Understanding Embeddings</h3>
<p>Before writing the loading code, it helps to understand what you're actually generating.</p>
<p>An embedding is an array of 768 numbers that represents the meaning of a piece of text. The model reads a sentence and outputs those 768 numbers in a way where similar sentences produce similar arrays of numbers.</p>
<p>When a user asks a question, you convert that question into an embedding using the same model, then ask Vectorize to find the stored embeddings that are closest to it. The documents those embeddings came from are your most relevant context.</p>
<p>This is why the model choice matters: your documents and your queries must be embedded with the same model, or the similarity scores will be meaningless.</p>
<h3 id="heading-how-to-build-the-load-endpoint">How to Build the Load Endpoint</h3>
<p>Open <code>src/index.ts</code> and update it with a <code>/load</code> route. Here is the complete file at this stage:</p>
<pre><code class="language-typescript">import { documents } from "../scripts/knowledge-base";

export interface Env {
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  LOAD_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env): Promise&lt;Response&gt; {
    const url = new URL(request.url);

    if (url.pathname === "/load" &amp;&amp; request.method === "POST") {
      return handleLoad(env, request);
    }

    return new Response("RAG tutorial worker is running", { status: 200 });
  },
};

async function handleLoad(env: Env, request: Request): Promise&lt;Response&gt; {
  const authHeader = request.headers.get("X-Load-Secret");
  if (authHeader !== env.LOAD_SECRET) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const results: { id: string; status: string }[] = [];

  for (const doc of documents) {
    const response = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
      text: [doc.text],
    }) as { data: number[][] };

    await env.VECTORIZE.upsert([
      {
        id: doc.id,
        values: response.data[0],
        metadata: {
          ...doc.metadata,
          text: doc.text,
        },
      },
    ]);

    results.push({ id: doc.id, status: "loaded" });
  }

  return Response.json({ success: true, loaded: results });
}
</code></pre>
<p>Notice that <code>env.AI.run()</code> and <code>env.VECTORIZE.upsert()</code> require no credentials. The bindings handle authentication because the Worker runs inside your Cloudflare account. There are no secrets to manage for internal service communication.</p>
<p>The <code>text: doc.text</code> field inside <code>metadata</code> is important. Vectorize stores the vector values and whatever metadata you provide, but it doesn't store the original text separately. By including the text in metadata, you can retrieve and display it in search results later.</p>
<p>The <code>as { data: number[][] }</code> cast is necessary because the TypeScript type definitions for Workers AI do not yet reflect the exact return shape of every model. The actual response always contains a <code>data</code> array, and the cast tells TypeScript to trust that.</p>
<h3 id="heading-how-to-deploy-and-load-your-knowledge-base">How to Deploy and Load Your Knowledge Base</h3>
<p>First, set the secret that will protect your load endpoint:</p>
<pre><code class="language-bash">npx wrangler secret put LOAD_SECRET
</code></pre>
<p>Type a strong value when prompted. Then deploy:</p>
<pre><code class="language-bash">npx wrangler deploy
</code></pre>
<p>Trigger the load endpoint. You only need to do this once, or any time you update your knowledge base:</p>
<pre><code class="language-bash">curl -X POST https://rag-tutorial-simple.&lt;your-subdomain&gt;.workers.dev/load \
  -H "X-Load-Secret: your-secret-value"
</code></pre>
<p>On Windows PowerShell:</p>
<p><strong>Note:</strong> PowerShell uses backtick (<code>`</code>) for line continuation, not backslash.</p>
<pre><code class="language-powershell">Invoke-WebRequest `
  -Uri "https://rag-tutorial-simple.&lt;your-subdomain&gt;.workers.dev/load" `
  -Method POST `
  -Headers @{"X-Load-Secret"="your-secret-value"} `
  -UseBasicParsing
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">{
  "success": true,
  "loaded": [
    { "id": "1", "status": "loaded" },
    { "id": "2", "status": "loaded" },
    { "id": "3", "status": "loaded" },
    { "id": "4", "status": "loaded" },
    { "id": "5", "status": "loaded" },
    { "id": "6", "status": "loaded" },
    { "id": "7", "status": "loaded" },
    { "id": "8", "status": "loaded" }
  ]
}
</code></pre>
<p>Your knowledge base is now stored in Vectorize as vectors. In the next section, you'll build the query pipeline that searches those vectors and generates answers.</p>
<h2 id="heading-how-to-build-the-query-pipeline">How to Build the Query Pipeline</h2>
<p>The query pipeline is the core of your RAG system. When a user sends a question, the pipeline runs four steps in sequence: embed the question, search Vectorize, build context from the results, and generate an answer with the LLM.</p>
<p>Add a <code>/query</code> route to your fetch handler and the complete <code>handleQuery</code> function. Here is the full updated <code>src/index.ts</code>:</p>
<pre><code class="language-typescript">import { documents } from "../scripts/knowledge-base";

export interface Env {
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  LOAD_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env): Promise&lt;Response&gt; {
    const url = new URL(request.url);

    if (url.pathname === "/load" &amp;&amp; request.method === "POST") {
      return handleLoad(env, request);
    }

    if (url.pathname === "/query" &amp;&amp; request.method === "POST") {
      return handleQuery(request, env);
    }

    return new Response("RAG tutorial worker is running", { status: 200 });
  },
};

async function handleLoad(env: Env, request: Request): Promise&lt;Response&gt; {
  const authHeader = request.headers.get("X-Load-Secret");
  if (authHeader !== env.LOAD_SECRET) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const results: { id: string; status: string }[] = [];

  for (const doc of documents) {
    const response = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
      text: [doc.text],
    }) as { data: number[][] };

    await env.VECTORIZE.upsert([
      {
        id: doc.id,
        values: response.data[0],
        metadata: {
          ...doc.metadata,
          text: doc.text,
        },
      },
    ]);

    results.push({ id: doc.id, status: "loaded" });
  }

  return Response.json({ success: true, loaded: results });
}

async function handleQuery(request: Request, env: Env): Promise&lt;Response&gt; {
  const body = await request.json() as { question: string };

  if (!body.question) {
    return Response.json({ error: "question is required" }, { status: 400 });
  }

  // Step 1: Embed the question using the same model as your documents
  const embeddingResponse = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
    text: [body.question],
  }) as { data: number[][] };

  // Step 2: Search Vectorize for the 3 most similar documents
  const searchResults = await env.VECTORIZE.query(
    embeddingResponse.data[0],
    {
      topK: 3,
      returnMetadata: "all",
    }
  );

  // Step 3: Build context from results above the similarity threshold
  const context = searchResults.matches
    .filter((match) =&gt; match.score &gt; 0.5)
    .map((match) =&gt; match.metadata?.text as string)
    .filter(Boolean)
    .join("\n\n");

  if (!context) {
    return Response.json({
      answer: "I could not find relevant information to answer that question.",
      sources: [],
    });
  }

  // Step 4: Generate an answer using the retrieved context
  const aiResponse = await env.AI.run("@cf/meta/llama-3.3-70b-instruct-fp8-fast", {
    messages: [
      {
        role: "system",
        content: "You are a helpful assistant. Answer the question using only the context provided. If the context does not contain enough information, say so.",
      },
      {
        role: "user",
        content: `Context:\n\({context}\n\nQuestion: \){body.question}`,
      },
    ],
    max_tokens: 256,
  }) as { response: string };

  // Step 5: Return the answer with its sources
  const sources = searchResults.matches
    .filter((match) =&gt; match.score &gt; 0.5)
    .map((match) =&gt; match.metadata?.source as string)
    .filter(Boolean);

  return Response.json({
    answer: aiResponse.response,
    sources: [...new Set(sources)],
  });
}
</code></pre>
<p>What each step does:</p>
<ol>
<li><p><strong>Step 1 – Embed the question</strong>: You convert the user's question into a 768-dimensional vector using the same model you used when loading your documents. This is critical: the question and the documents must be embedded with the same model or the similarity scores will be meaningless.</p>
</li>
<li><p><strong>Step 2 – Search Vectorize</strong>: You pass the question embedding to Vectorize, which returns the three most similar documents. <code>returnMetadata: "all"</code> tells Vectorize to include the metadata you stored alongside each vector — including the original text.</p>
</li>
<li><p><strong>Step 3 – Build context</strong>: You filter out any results with a similarity score below 0.5 and join the remaining document texts into a single context string. The 0.5 threshold prevents the LLM from receiving irrelevant documents just because nothing better matched.</p>
</li>
<li><p><strong>Step 4 – Generate the answer</strong>: You pass the context and the question to the LLM using the chat format with <code>messages</code>. The system prompt explicitly instructs the model to answer using only the provided context. This is what keeps the LLM grounded. Without this instruction, it will ignore your context and answer from its training data instead.</p>
</li>
<li><p><strong>Step 5 – Return sources</strong>: You include the source metadata in the response so callers know which documents the answer came from. The <code>Set</code> deduplicates sources in case multiple chunks came from the same document.</p>
</li>
</ol>
<h3 id="heading-how-to-test-the-query-pipeline">How to Test the Query Pipeline</h3>
<p>Deploy your Worker:</p>
<pre><code class="language-bash">npx wrangler deploy
</code></pre>
<p>Send a question:</p>
<pre><code class="language-bash">curl -X POST https://rag-tutorial-simple.&lt;your-subdomain&gt;.workers.dev/query \
  -H "Content-Type: application/json" \
  -d '{"question": "What is RAG?"}'
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-WebRequest `
  -Uri "https://rag-tutorial-simple.&lt;your-subdomain&gt;.workers.dev/query" `
  -Method POST `
  -ContentType "application/json" `
  -Body '{"question": "What is RAG?"}' `
  -UseBasicParsing
</code></pre>
<p>You should receive a response like this:</p>
<pre><code class="language-json">{
  "answer": "RAG stands for Retrieval Augmented Generation. It's a method that enhances generation by retrieving relevant context from a knowledge base and adding it to the prompt before generating an answer.",
  "sources": ["ai-concepts"]
}
</code></pre>
<p>The answer came from your knowledge base, not from the LLM's training data. That's the entire point of RAG: grounded, verifiable answers with traceable sources.</p>
<h2 id="heading-how-to-add-error-handling-and-security">How to Add Error Handling and Security</h2>
<p>A tutorial that only shows the happy path is not production-ready. In this section, you'll add error handling to every step of the query pipeline and protect the <code>/load</code> endpoint from unauthorized access.</p>
<h3 id="heading-how-to-secure-the-load-endpoint">How to Secure the Load Endpoint</h3>
<p>The <code>/load</code> endpoint generates embeddings and writes to your Vectorize index. Without protection, anyone who discovers your Worker URL can trigger it repeatedly, consuming your Workers AI quota and overwriting your data.</p>
<p>The <code>LOAD_SECRET</code> binding you added to <code>Env</code> and the <code>wrangler secret put</code> command you ran earlier handle this. The check at the top of <code>handleLoad</code> rejects any request that doesn't include the correct secret header:</p>
<pre><code class="language-typescript">const authHeader = request.headers.get("X-Load-Secret");
if (authHeader !== env.LOAD_SECRET) {
  return Response.json({ error: "Unauthorized" }, { status: 401 });
}
</code></pre>
<p>A request without the header returns <code>{"error":"Unauthorized"}</code> with a 401 status. The secret itself is stored as an encrypted environment variable in your Worker. It never appears in your code or <code>wrangler.toml</code>.</p>
<p>To trigger the load endpoint, you must include the secret in the request header:</p>
<pre><code class="language-bash">curl -X POST https://rag-tutorial-simple.&lt;your-subdomain&gt;.workers.dev/load \
  -H "X-Load-Secret: your-secret-value"
</code></pre>
<h3 id="heading-how-to-handle-query-errors">How to Handle Query Errors</h3>
<p>Replace your <code>handleQuery</code> function with this hardened version:</p>
<pre><code class="language-typescript">async function handleQuery(request: Request, env: Env): Promise&lt;Response&gt; {
  // Guard against malformed request body
  let body: { question: string };
  try {
    body = await request.json() as { question: string };
  } catch {
    return Response.json({ error: "Invalid JSON in request body" }, { status: 400 });
  }

  if (!body.question || typeof body.question !== "string" || body.question.trim() === "") {
    return Response.json({ error: "question must be a non-empty string" }, { status: 400 });
  }

  // Sanitize: trim whitespace and cap length
  const question = body.question.trim().slice(0, 500);

  // Step 1: Embed the question
  let embeddingResponse: { data: number[][] };
  try {
    embeddingResponse = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
      text: [question],
    }) as { data: number[][] };
  } catch (err) {
    console.error("Embedding generation failed:", err);
    return Response.json({ error: "Failed to process your question" }, { status: 503 });
  }

  // Step 2: Search Vectorize
  let searchResults: Awaited&lt;ReturnType&lt;typeof env.VECTORIZE.query&gt;&gt;;
  try {
    searchResults = await env.VECTORIZE.query(
      embeddingResponse.data[0],
      { topK: 3, returnMetadata: "all" }
    );
  } catch (err) {
    console.error("Vectorize query failed:", err);
    return Response.json({ error: "Failed to search knowledge base" }, { status: 503 });
  }

  // Step 3: Build context
  const context = searchResults.matches
    .filter((match) =&gt; match.score &gt; 0.5)
    .map((match) =&gt; match.metadata?.text as string)
    .filter(Boolean)
    .join("\n\n");

  if (!context) {
    return Response.json({
      answer: "I could not find relevant information to answer that question. Try rephrasing or asking something else.",
      sources: [],
    });
  }

  // Step 4: Generate answer
  let aiResponse: { response: string };
  try {
    aiResponse = await env.AI.run("@cf/meta/llama-3.3-70b-instruct-fp8-fast", {
      messages: [
        {
          role: "system",
          content: "You are a helpful assistant. Answer the question using only the context provided. If the context does not contain enough information, say so.",
        },
        {
          role: "user",
          content: `Context:\n\({context}\n\nQuestion: \){question}`,
        },
      ],
      max_tokens: 256,
    }) as { response: string };
  } catch (err) {
    console.error("LLM generation failed:", err);
    return Response.json({ error: "Failed to generate an answer" }, { status: 503 });
  }

  // Step 5: Return answer with sources
  const sources = searchResults.matches
    .filter((match) =&gt; match.score &gt; 0.5)
    .map((match) =&gt; match.metadata?.source as string)
    .filter(Boolean);

  return Response.json({
    answer: aiResponse.response,
    sources: [...new Set(sources)],
  });
}
</code></pre>
<p>What each error handling decision means:</p>
<ul>
<li><p><code>try/catch</code> <strong>around</strong> <code>request.json()</code>: <code>request.json()</code> throws if the body is not valid JSON. Without this catch, a malformed request crashes your Worker with an unhandled 500 error. With it, the caller gets a clear 400 explaining what went wrong.</p>
</li>
<li><p><strong>Input validation before processing</strong>: You check that <code>question</code> exists, is a string, and is not empty before calling any external service. This prevents wasted AI calls on invalid input.</p>
</li>
<li><p><code>.slice(0, 500)</code> <strong>on the question</strong>: This caps the input length before it reaches the embedding model. Without it, a malicious caller could send a very long string designed to inflate your AI usage or hit Workers CPU limits.</p>
</li>
<li><p><strong>503 for AI and Vectorize failures</strong>: HTTP 503 means "service temporarily unavailable." It signals to callers that the error is on the server side and the request can be retried.</p>
</li>
<li><p><code>.filter(Boolean)</code> <strong>on context</strong>: After mapping <code>match.metadata?.text</code>, some results may be <code>undefined</code> if metadata was stored without a <code>text</code> field. This filters them out before joining, preventing <code>"undefined"</code> from appearing in the context string you send to the LLM.</p>
</li>
</ul>
<h3 id="heading-how-to-test-error-handling">How to Test Error Handling</h3>
<p>Deploy your updated Worker:</p>
<pre><code class="language-bash">npx wrangler deploy
</code></pre>
<p>Test each error case:</p>
<pre><code class="language-bash"># Missing secret on load endpoint — should return 401
curl -X POST https://rag-tutorial-simple.&lt;your-subdomain&gt;.workers.dev/load

# Invalid JSON — should return 400
curl -X POST https://rag-tutorial-simple.&lt;your-subdomain&gt;.workers.dev/query \
  -H "Content-Type: application/json" \
  -d 'not json'

# Empty question — should return 400
curl -X POST https://rag-tutorial-simple.&lt;your-subdomain&gt;.workers.dev/query \
  -H "Content-Type: application/json" \
  -d '{"question": ""}'
</code></pre>
<h2 id="heading-performance-and-cost-analysis">Performance and Cost Analysis</h2>
<p>This section uses real production data from my <a href="https://github.com/dannwaneri/vectorize-mcp-worker">vectorize-mcp-worker</a> deployment. It uses the same architecture you just built, measured from Port Harcourt, Nigeria to Cloudflare's edge.</p>
<h3 id="heading-real-performance-numbers">Real Performance Numbers</h3>
<p>Here is what the pipeline actually costs in time on every request:</p>
<table>
<thead>
<tr>
<th>Operation</th>
<th>Time</th>
</tr>
</thead>
<tbody><tr>
<td>Embedding generation</td>
<td>142ms</td>
</tr>
<tr>
<td>Vector search</td>
<td>223ms</td>
</tr>
<tr>
<td>Response formatting</td>
<td>&lt;5ms</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>~365ms</strong></td>
</tr>
</tbody></table>
<p>This covers embedding generation and vector search only – the retrieval layer. LLM generation adds 500-1500ms on top, which is why end-to-end response time typically runs 600-1600ms.</p>
<p>The embedding step and vector search dominate. Everything else is negligible. For context, a comparable setup using OpenAI embeddings and Pinecone would add two external API roundtrips on top of this, easily pushing total latency past 1 second.</p>
<p>These numbers come from a single-region measurement. Your actual latency will vary based on your location and Cloudflare's load at the time of the request. The architectural point holds regardless: co-locating everything on the edge eliminates inter-service network hops, which is where most latency in traditional RAG stacks comes from.</p>
<h3 id="heading-real-cost-breakdown">Real Cost Breakdown</h3>
<p>For 10,000 searches per day (300,000 per month) with 10,000 stored vectors:</p>
<p><strong>This stack:</strong></p>
<table>
<thead>
<tr>
<th>Service</th>
<th>Monthly Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Workers</td>
<td>~$3</td>
</tr>
<tr>
<td>Workers AI</td>
<td>~$3-5</td>
</tr>
<tr>
<td>Vectorize</td>
<td>~$2</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>$8-10</strong></td>
</tr>
</tbody></table>
<p><strong>Traditional alternatives for the same volume:</strong></p>
<table>
<thead>
<tr>
<th>Solution</th>
<th>Monthly Cost</th>
</tr>
</thead>
<tbody><tr>
<td>Pinecone Standard</td>
<td>$50-70</td>
</tr>
<tr>
<td>Weaviate Serverless</td>
<td>$25-40</td>
</tr>
<tr>
<td>Self-hosted pgvector</td>
<td>$40-60</td>
</tr>
</tbody></table>
<p>That is an 85-95% cost reduction depending on which alternative you compare against. For a bootstrapped startup adding semantic search, that difference is $1,500-2,000 per year.</p>
<h3 id="heading-why-the-cost-difference-is-so-large">Why the Cost Difference Is So Large</h3>
<p>Traditional RAG stacks have three cost problems that compound each other.</p>
<p>The first is idle compute. A dedicated server or container running your embedding service costs money even when no searches are happening. Cloudflare Workers charge only for actual execution time.</p>
<p>The second is inter-service data transfer. Every time your application calls an external service for an embedding, then calls a separate service for a search, you're paying for two external API calls with metered pricing. In this stack, both operations happen inside Cloudflare's network at no additional transfer cost.</p>
<p>The third is minimum plan pricing. Pinecone's Standard plan costs \(50/month as a floor, regardless of how little you use it. Cloudflare's pricing scales from the \)5/month Workers Paid plan base.</p>
<h3 id="heading-when-the-included-allocation-is-enough">When the Included Allocation Is Enough</h3>
<p>For smaller usage levels, you may not pay beyond the $5/month Workers Paid base price:</p>
<ul>
<li><p>Workers: 10 million requests per month included</p>
</li>
<li><p>Workers AI: generous daily neuron allocation included</p>
</li>
<li><p>Vectorize: available on both Free and Paid plans, with a free allocation included</p>
</li>
</ul>
<p>A side project, internal tool, or small business with under 3,000 searches per day will likely stay within the included allocations entirely.</p>
<h3 id="heading-the-trade-off-to-know-about">The Trade-off to Know About</h3>
<p>This cost advantage comes with one operational constraint worth understanding before you build: Vectorize does not work in local development mode.</p>
<p>When you run <code>wrangler dev</code>, your Worker runs locally but Vectorize calls fail. You have to deploy to Cloudflare to test your vector search. For most development workflows this means testing your query logic locally with mocked responses, then deploying to a staging environment for full integration tests.</p>
<p>This is a real friction point. It's the honest trade-off for having a managed vector database with no infrastructure to operate.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you have built and deployed a production-ready RAG system on Cloudflare's edge network. Let's look at what you actually built and what it costs to run.</p>
<h3 id="heading-what-you-built">What You Built</h3>
<p>Your completed system has three endpoints:</p>
<ul>
<li><p><code>GET /</code>: health check confirming the Worker is running</p>
</li>
<li><p><code>POST /load</code>: loads your knowledge base into Vectorize, protected by a secret header</p>
</li>
<li><p><code>POST /query</code>: accepts a question, retrieves relevant context, and returns a grounded answer with sources</p>
</li>
</ul>
<p>The full query pipeline runs in four steps on every request:</p>
<ol>
<li><p>The question is converted to a 768-dimensional embedding using <code>@cf/baai/bge-base-en-v1.5</code></p>
</li>
<li><p>Vectorize finds the three most semantically similar documents</p>
</li>
<li><p>Documents above the 0.5 similarity threshold are assembled into context</p>
</li>
<li><p>Llama 3.3 generates an answer using only that context</p>
</li>
</ol>
<p>Everything runs on Cloudflare's infrastructure. No external API keys. No separate vector database subscription. No servers to manage.</p>
<h3 id="heading-what-to-build-next">What to Build Next</h3>
<p>This tutorial covered the core RAG pattern. Here are four directions to take it further.</p>
<h4 id="heading-add-more-documents">Add more documents</h4>
<p>The knowledge base in this tutorial has 8 documents. A real system might have thousands. The loading pattern is identical: add documents to <code>knowledge-base.ts</code>, hit <code>/load</code> with your secret, and Vectorize handles the rest.</p>
<p>For very large knowledge bases, update <code>handleLoad</code> to batch documents in groups of 20-50 rather than upserting one at a time.</p>
<h4 id="heading-improve-chunking">Improve chunking</h4>
<p>Each document in this tutorial is a single short paragraph. Real-world documents like PDFs, articles, documentation pages need to be split into chunks before embedding. Chunk at natural boundaries like paragraphs and sentences, aim for 200-400 tokens per chunk, and include 50-token overlaps between chunks to preserve context across boundaries.</p>
<h4 id="heading-add-conversation-history">Add conversation history</h4>
<p>The current system treats every query as independent. To support follow-up questions, store previous messages in a Cloudflare KV namespace and include the last 2-3 exchanges in the LLM <code>messages</code> array alongside the retrieved context.</p>
<h4 id="heading-stream-the-response">Stream the response</h4>
<p>For long answers, users stare at a blank screen until generation completes. Cloudflare Workers support streaming responses via <code>TransformStream</code>. Switching to streaming means the first tokens appear in under 100ms while the rest generates.</p>
<h4 id="heading-consider-dimensions-vs-reranking-trade-offs">Consider dimensions vs reranking trade-offs</h4>
<p>This tutorial uses <code>bge-base-en-v1.5</code> at 768 dimensions. My production system uses <code>bge-small-en-v1.5</code> at 384 dimensions. Testing showed upgrading from 384 to 768 dims only improved accuracy by about 2%, but doubled cost and latency.</p>
<p>Adding a reranker (<code>@cf/baai/bge-reranker-base</code>) gave a larger accuracy improvement than the dimension upgrade for a fraction of the cost. The exact improvement will vary by domain and query distribution — test both on your actual data before deciding. If you're optimizing for production, add a reranker before you increase dimensions.</p>
<h3 id="heading-the-complete-project">The Complete Project</h3>
<p>Clone and deploy in five commands:</p>
<pre><code class="language-bash">git clone https://github.com/dannwaneri/rag-tutorial-simple
cd rag-tutorial-simple
npm install
npx wrangler vectorize create rag-tutorial-index --dimensions=768 --metric=cosine
npx wrangler secret put LOAD_SECRET
npx wrangler deploy
</code></pre>
<p>Then load your knowledge base:</p>
<pre><code class="language-bash">curl -X POST https://&lt;your-worker&gt;.workers.dev/load \
  -H "X-Load-Secret: your-secret"
</code></pre>
<p>If you found this useful, the production system this tutorial is based on is open source at <a href="https://github.com/dannwaneri/vectorize-mcp-worker">github.com/dannwaneri/vectorize-mcp-worker</a>. It extends this foundation with hybrid search combining vector and BM25, multimodal support for searching images with AI vision, a reranker for more accurate results, and a live dashboard. It runs on the same Cloudflare stack you just built – Workers, Vectorize, Workers AI – plus D1 for document storage.</p>
<p>One difference you'll notice: the production system uses <code>bge-small-en-v1.5</code> at 384 dimensions rather than the 768 dimensions in this tutorial. That is an intentional trade-off: the reranker adds more accuracy than the extra dimensions at lower cost. The jump from what you built today to that system is smaller than it looks.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Embeddable AI Chatbot Widget with Cloudflare Workers ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever wanted to add an AI-powered chatbot to your website, like Intercom or Drift, without paying high monthly fees? In this tutorial, you'll learn how to build a fully functional, embeddable AI chatbot widget using Cloudflare's serverless st... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-embeddable-ai-chatbot-widget-with-cloudflare-workers/</link>
                <guid isPermaLink="false">695bde18d63e0c63c9fefea8</guid>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare-worker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI Chat Bot ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mayur Vekariya ]]>
                </dc:creator>
                <pubDate>Mon, 05 Jan 2026 15:51:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1767626158079/0b9e58c9-9299-4342-8c97-1a2de185cc60.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever wanted to add an AI-powered chatbot to your website, like Intercom or Drift, without paying high monthly fees? In this tutorial, you'll learn how to build a fully functional, embeddable AI chatbot widget using Cloudflare's serverless stack.</p>
<p>You will build a production-ready AI chatbot widget that you can embed on any website with a single script tag. It’ll be similar to Intercom or Drift – but it’s completely free and under your control.</p>
<p>By the end, you will have a chatbot that:</p>
<ul>
<li><p>Streams AI responses in real-time for a natural typing effect</p>
</li>
<li><p>Answers questions from your FAQ using RAG (Retrieval Augmented Generation)</p>
</li>
<li><p>Remembers conversations across page reloads</p>
</li>
<li><p>Supports dark and light modes</p>
</li>
<li><p>Works on any website with one line of code</p>
</li>
</ul>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-you-will-build">What You Will Build</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-configure-wrangler">How to Configure Wrangler</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-the-backend-worker">How to Build the Backend Worker</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-set-up-tailwind-css">How to Set Up Tailwind CSS</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-build-the-frontend-widget">How to Build the Frontend Widget</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-the-demo-page">Create the Demo Page</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-run-it-in-your-local-system">How to Run it in Your Local System</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-deploy-to-cloudflare">How to Deploy to Cloudflare</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-embed-the-widget-on-any-website">How to Embed the Widget on Any Website</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-customize-your-chatbot">How to Customize Your Chatbot</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>A <a target="_blank" href="https://dash.cloudflare.com/sign-up">Cloudflare account</a> (the free tier works perfectly)</p>
</li>
<li><p><a target="_blank" href="https://nodejs.org/">Node.js</a> version 18 or higher installed on your computer</p>
</li>
<li><p>Basic knowledge of JavaScript</p>
</li>
</ul>
<p>You do not need any prior experience with Cloudflare Workers.</p>
<h2 id="heading-what-you-will-build"><strong>What You Will Build</strong></h2>
<p>Your chatbot will have two main parts:</p>
<ol>
<li><p><strong>Backend Worker</strong> (<strong>src/index.js</strong>): Handles chat requests, manages sessions, and connects to AI</p>
</li>
<li><p><strong>Frontend Widget</strong> (<strong>public/widget.js</strong>): The embeddable UI that users interact with</p>
</li>
</ol>
<p>You will use four Cloudflare services:</p>
<ul>
<li><p><strong>Workers AI</strong>: Powers the AI responses using Meta's Llama 3 model</p>
</li>
<li><p><strong>Vectorize</strong>: Stores and searches your FAQ for relevant context (this is the RAG part)</p>
</li>
<li><p><strong>KV</strong>: Persists conversation history between sessions</p>
</li>
<li><p><strong>Workers</strong>: Runs your serverless backend at the edge</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-the-project"><strong>How to Set Up the Project</strong></h2>
<p>First, create a new Cloudflare Workers project. Open your terminal and run the following command.</p>
<p>When it asks you for the programming language, select <code>javascript</code>, and when it asks, "Do you want to deploy your application?" select <code>no</code>, since we’re going to deploy at the end.</p>
<pre><code class="lang-powershell">npm create cloudflare@latest ai<span class="hljs-literal">-chatbot</span><span class="hljs-literal">-widget</span> -- -<span class="hljs-literal">-type</span>=hello<span class="hljs-literal">-world</span>
</code></pre>
<p>Navigate into your new project directory:</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">cd</span> ai<span class="hljs-literal">-chatbot</span><span class="hljs-literal">-widget</span>
</code></pre>
<p>And install the required development dependencies:</p>
<pre><code class="lang-powershell">npm install -<span class="hljs-literal">-save</span><span class="hljs-literal">-dev</span> tailwindcss autoprefixer postcss wrangler
</code></pre>
<p>Your project is now ready for development.</p>
<h2 id="heading-how-to-configure-wrangler"><strong>How to Configure Wrangler</strong></h2>
<p>Wrangler is Cloudflare's command-line tool for developing and deploying Workers. You need to configure it to use the required services.</p>
<p>A <a target="_blank" href="https://workers.cloudflare.com">Cloudflare Worker</a> is a serverless function that runs on Cloudflare's global edge network. Unlike traditional servers that run in a single location, Workers execute as close to your users as possible using more than 300 data centers worldwide. This results in faster response times and lower latency. You just write the JavaScript code, and Cloudflare takes care of all the infrastructure, scaling, and deployment.</p>
<h3 id="heading-create-resources-one-time-setup">Create Resources (One-Time Setup)</h3>
<p>The following resources are created via the Wrangler CLI (recommended for automation).</p>
<p>First, install Wrangler (if you don’t have it already):</p>
<pre><code class="lang-bash">npm install -g wrangler
</code></pre>
<p>To login, use <code>wrangler login</code>. This command will open a Cloudflare browser tab where you will need to authorize.</p>
<h3 id="heading-create-a-vectorize-index-for-rag">Create a vectorize index (for RAG):</h3>
<p>A <a target="_blank" href="https://developers.cloudflare.com/vectorize/"><strong>vectorize index</strong></a> is a vector database that lets you perform semantic search. Instead of searching for exact keyword matches (like in traditional databases), Vectorize finds content based on meaning.</p>
<p>Here's how it works: You convert your FAQ questions and answers into numerical vectors (called embeddings) using an AI model. When a user asks a question, the chatbot converts that question into a vector and finds the FAQ entries with the most similar vectors. This is the "RAG" (<a target="_blank" href="https://www.freecodecamp.org/news/retrieval-augmented-generation-rag-handbook/">Retrieval Augmented Generation</a>) technique, which augments the AI's response with relevant context from your knowledge base.</p>
<pre><code class="lang-bash">npx wrangler vectorize create faq-vectors --dimensions=768 --metric=cosine
</code></pre>
<h3 id="heading-create-kv-namespace-for-session-history">Create KV namespace (for session history):</h3>
<p><a target="_blank" href="https://developers.cloudflare.com/kv/"><strong>KV (Key-Value) storage</strong></a> is Cloudflare's globally distributed database for storing simple data. Think of it like a giant dictionary: you store data using a key (the session ID) and retrieve it later using that same key.</p>
<p>For your chatbot, KV stores each user's conversation history. When a user returns to your website, the chatbot retrieves their session from KV and remembers what they talked about before.</p>
<pre><code class="lang-javascript">npx wrangler kv namespace create CHAT_SESSIONS
</code></pre>
<p>Note the id from the output as you'll add it in the <code>wrangler.jsonc</code> file.</p>
<p>Create a file called <code>wrangler.jsonc</code> in your project root (you just need to replace <code>YOUR_KV_NAMESPACE_ID</code> with the ID that you received in the last step):</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"$schema"</span>: <span class="hljs-string">"node_modules/wrangler/config-schema.json"</span>,
  <span class="hljs-attr">"name"</span>: <span class="hljs-string">"ai-chatbot-widget"</span>,
  <span class="hljs-attr">"main"</span>: <span class="hljs-string">"src/index.js"</span>,
  <span class="hljs-attr">"compatibility_date"</span>: <span class="hljs-string">"2025-12-23"</span>,
  <span class="hljs-attr">"observability"</span>: {
    <span class="hljs-attr">"enabled"</span>: <span class="hljs-literal">true</span>
  },
  <span class="hljs-attr">"assets"</span>: {
    <span class="hljs-attr">"directory"</span>: <span class="hljs-string">"./public"</span>,
    <span class="hljs-attr">"binding"</span>: <span class="hljs-string">"ASSETS"</span>
  },
  <span class="hljs-attr">"ai"</span>: {
    <span class="hljs-attr">"binding"</span>: <span class="hljs-string">"AI"</span>
  },
  <span class="hljs-attr">"vectorize"</span>: [
    {
      <span class="hljs-attr">"binding"</span>: <span class="hljs-string">"VECTORIZE"</span>,
      <span class="hljs-attr">"index_name"</span>: <span class="hljs-string">"faq-vectors"</span>
    }
  ],
  <span class="hljs-attr">"kv_namespaces"</span>: [
    {
      <span class="hljs-attr">"binding"</span>: <span class="hljs-string">"CHAT_SESSIONS"</span>,
      <span class="hljs-attr">"id"</span>: <span class="hljs-string">"YOUR_KV_NAMESPACE_ID"</span>
    }
  ]
}
</code></pre>
<p>This configuration file tells Wrangler which Cloudflare services your Worker needs access to.</p>
<p>Let me explain the key bindings:</p>
<ul>
<li><p><strong>ASSETS</strong>: Serves static files (like your widget JavaScript and CSS) from the <code>public</code> folder</p>
</li>
<li><p><strong>AI</strong>: Connects to Cloudflare's Workers AI for running machine learning models</p>
</li>
<li><p><strong>VECTORIZE</strong>: Links to your Vectorize index for storing and searching FAQ embeddings</p>
</li>
<li><p><strong>CHAT_SESSIONS</strong>: Connects to a KV namespace for storing conversation history</p>
</li>
</ul>
<h2 id="heading-how-to-build-the-backend-worker">How to Build the Backend Worker</h2>
<p>The backend Worker is the brain of your chatbot. It handles incoming chat messages, searches your FAQ for relevant context, sends the conversation to the AI, streams the response back to the user, and saves everything to KV for later.</p>
<p>Create the file <code>src/index.js</code> with this code:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">/** AI Chatbot Widget - Cloudflare Worker */</span>
<span class="hljs-keyword">const</span> SYS = <span class="hljs-string">`You are a helpful customer support assistant. Be friendly, professional, and concise. Use the FAQ context to give accurate answers. If you don't know something, say so.`</span>;
<span class="hljs-keyword">const</span> TTL = <span class="hljs-number">30</span>*<span class="hljs-number">24</span>*<span class="hljs-number">60</span>*<span class="hljs-number">60</span>;
<span class="hljs-keyword">const</span> cors = { <span class="hljs-string">'Access-Control-Allow-Origin'</span>: <span class="hljs-string">'*'</span> };
<span class="hljs-keyword">const</span> json = <span class="hljs-function">(<span class="hljs-params">d, s=<span class="hljs-number">200</span>, h={}</span>) =&gt;</span> <span class="hljs-keyword">new</span> Response(<span class="hljs-built_in">JSON</span>.stringify(d), { <span class="hljs-attr">status</span>: s, <span class="hljs-attr">headers</span>: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span>, ...cors, ...h } });
<span class="hljs-keyword">const</span> cookie = <span class="hljs-function"><span class="hljs-params">r</span> =&gt;</span> r.headers.get(<span class="hljs-string">'Cookie'</span>)?.match(<span class="hljs-regexp">/chatbot_session=([^;]+)/</span>)?.[<span class="hljs-number">1</span>];

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

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

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

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

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

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

<span class="hljs-tag">&lt;/<span class="hljs-name">html</span>&gt;</span>
</code></pre>
<p>This minimal page displays a title and loads the chatbot widget. The <code>CHATBOT_BASE_URL</code> is set to an empty string because when served from the same Worker, relative URLs work automatically. This is the exact same code someone would use to embed the widget on their own website, just with their own base URL instead.</p>
<p>With all the code in place, you are ready to deploy your chatbot to Cloudflare.</p>
<h2 id="heading-how-to-run-it-in-your-local-system">How to Run it in Your Local System</h2>
<p>Once all the files are added, run the command <code>npm run dev</code> to see how the chat widget looks in <code>http://localhost:8787</code>:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1766525553600/6a0dd8ed-5f2f-49ad-924d-3ef2fb143a42.png" alt="How your chatbot should look" class="image--center mx-auto" width="935" height="828" loading="lazy"></p>
<h2 id="heading-how-to-deploy-to-cloudflare"><strong>How to Deploy to Cloudflare</strong></h2>
<p>Deployment is a single command. Run:</p>
<pre><code class="lang-powershell">npm run deploy
</code></pre>
<p>This command first builds your Tailwind CSS, then deploys everything to Cloudflare. After deployment completes, you will see a URL like <code>https://ai-chatbot-widget.YOUR-SUBDOMAIN.workers.dev</code>.</p>
<p>in my case URL is <a target="_blank" href="https://ai-chatbot-widget.mv.workers.dev/">https://ai-chatbot-widget.mv.workers.dev/</a></p>
<h3 id="heading-how-to-seed-the-faq-database">How to Seed the FAQ Database</h3>
<p>Before your chatbot can answer questions from your FAQ, you need to populate the Vectorize index. Run this command (replace the URL with your actual deployment URL):</p>
<pre><code class="lang-powershell"><span class="hljs-built_in">curl</span> <span class="hljs-literal">-X</span> POST https://ai<span class="hljs-literal">-chatbot</span><span class="hljs-literal">-widget</span>.YOUR<span class="hljs-literal">-SUBDOMAIN</span>.workers.dev/api/seed
</code></pre>
<p>You should see this response:</p>
<pre><code class="lang-json">{<span class="hljs-attr">"success"</span>:<span class="hljs-literal">true</span>,<span class="hljs-attr">"count"</span>:<span class="hljs-number">8</span>}
</code></pre>
<p>This means eight FAQ entries have been converted to vectors and stored in Vectorize. Your chatbot is now live and ready to answer questions!</p>
<p>Visit your deployment URL to test it out. Try asking about shipping, returns, or payment methods. The chatbot will respond using the FAQ context you just seeded.</p>
<p>Your chatbot is now live and ready to answer questions. You can check the Cloudflare dashboard to view the deployment. (The screenshot below is from the Cloudflare dashboard.)</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1766526373608/54e674c1-0c7c-4187-bf6f-e227763f7cef.png" alt="54e674c1-0c7c-4187-bf6f-e227763f7cef" class="image--center mx-auto" width="1008" height="651" loading="lazy"></p>
<h2 id="heading-how-to-embed-the-widget-on-any-website">How to Embed the Widget on Any Website</h2>
<p>Now for the exciting part: adding your chatbot to any website. All it takes is two script tags before the closing <code>&lt;/body&gt;</code> tag:</p>
<pre><code class="lang-xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span>&gt;</span><span class="javascript">
  <span class="hljs-built_in">window</span>.CHATBOT_BASE_URL = <span class="hljs-string">'https://ai-chatbot-widget.YOUR-SUBDOMAIN.workers.dev'</span>;
  <span class="hljs-built_in">window</span>.CHATBOT_TITLE = <span class="hljs-string">'Your Company'</span>;
  <span class="hljs-built_in">window</span>.CHATBOT_GREETING = <span class="hljs-string">'👋 How can I help you today?'</span>;
</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
<span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">src</span>=<span class="hljs-string">"https://ai-chatbot-widget.YOUR-SUBDOMAIN.workers.dev/widget.js"</span>&gt;</span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span>
</code></pre>
<p>Replace <code>YOUR-SUBDOMAIN</code> with your actual Cloudflare Workers subdomain.</p>
<p>Or you can also open your Cloudflare deployment URL for testing.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1766526312388/9f9a2b18-839d-44d8-b6c2-6f185c61442d.gif" alt="Testing the chatbot" class="image--center mx-auto" width="800" height="718" loading="lazy"></p>
<h3 id="heading-configuration-options">Configuration Options</h3>
<p>You can customize the widget using these variables:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td><strong>Variable</strong></td><td><strong>Description</strong></td><td><strong>Default</strong></td></tr>
</thead>
<tbody>
<tr>
<td><code>CHATBOT_BASE_URL</code></td><td>Your deployed Worker URL</td><td><code>''</code> (same origin)</td></tr>
<tr>
<td><code>CHATBOT_TITLE</code></td><td>Name shown in the header</td><td><code>'AI Assistant'</code></td></tr>
<tr>
<td><code>CHATBOT_PLACEHOLDER</code></td><td>Input field placeholder</td><td><code>'Message...'</code></td></tr>
<tr>
<td><code>CHATBOT_GREETING</code></td><td>Initial greeting message</td><td><code>'👋 Hi! How can I help you today?'</code></td></tr>
</tbody>
</table>
</div><h2 id="heading-how-to-customize-your-chatbot">How to Customize Your Chatbot</h2>
<p>Your chatbot is working, but you probably want to tailor it to your specific use case. Here are the most common customizations.</p>
<h3 id="heading-how-to-add-your-own-faqs">How to Add Your Own FAQs</h3>
<p>Open <code>src/index.js</code> and find the <code>seed</code> function. Replace the sample FAQs with your own question-answer pairs:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> faqs = [
  [<span class="hljs-string">'Your question here?'</span>, <span class="hljs-string">'Your answer here.'</span>],
  [<span class="hljs-string">'Another question?'</span>, <span class="hljs-string">'Another answer.'</span>]
  <span class="hljs-comment">// Add more Q&amp;A pairs</span>
];
</code></pre>
<p>Then redeploy with <code>npm run deploy</code> and call the <code>/api/seed</code> endpoint again to update your vector database.</p>
<h3 id="heading-how-to-change-the-ai-personality">How to Change the AI Personality</h3>
<p>Edit the <code>SYS</code> constant at the top of <code>src/index.js</code>:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> SYS = <span class="hljs-string">`You are a friendly assistant for [Your Company].
You help customers with [your main services].
Always be helpful and professional.`</span>;
</code></pre>
<p>This system prompt shapes how the AI responds to users.</p>
<h3 id="heading-how-to-style-the-widget">How to Style the Widget</h3>
<p>All styles use Tailwind CSS classes in <code>widget.js</code>. To change the appearance:</p>
<ul>
<li><p><strong>Colors</strong>: Change <code>bg-black</code> to your brand color</p>
</li>
<li><p><strong>Size</strong>: Adjust <code>w-[400px] h-[600px]</code> for the chat window dimensions</p>
</li>
<li><p><strong>Position</strong>: Modify <code>bottom-6 right-6</code> for placement</p>
</li>
</ul>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>Congratulations! You have built a complete AI chatbot widget that rivals expensive SaaS solutions like Intercom and Drift. Your chatbot streams AI responses in real-time, answers questions based on your FAQ using RAG, and remembers conversations across sessions—all for free.</p>
<p>Here is a quick recap of what you built:</p>
<ul>
<li><p>A backend Worker that handles chat, sessions, and FAQ search</p>
</li>
<li><p>A frontend widget that can be embedded on any website</p>
</li>
<li><p>Integration with Workers AI for intelligent responses</p>
</li>
<li><p>Vectorize for semantic FAQ search</p>
</li>
<li><p>KV for persistent conversation history</p>
</li>
</ul>
<p>The Cloudflare stack offers generous free tiers that should cover most use cases:</p>
<ul>
<li><p><strong>Workers</strong>: 100,000 requests per day</p>
</li>
<li><p><strong>Workers AI</strong>: 10,000 neurons per day</p>
</li>
<li><p><strong>Vectorize</strong>: 5 million vector operations per month</p>
</li>
<li><p><strong>KV</strong>: 100,000 reads and 1,000 writes per day</p>
</li>
</ul>
<p>For most websites, you can run this chatbot completely free.</p>
<p>The source code for this project is available on <a target="_blank" href="https://github.com/mayur9210/ai-chatbot-widget">GitHub</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Secure SSR Authentication with Supabase, Astro, and Cloudflare Turnstile ]]>
                </title>
                <description>
                    <![CDATA[ In this guide, you'll build a full server-side rendered (SSR) authentication system using Astro, Supabase, and Cloudflare Turnstile to protect against bots. By the end, you'll have a fully functional authentication system with Astro actions, magic li... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-secure-ssr-authentication-with-supabase-astro-and-cloudflare-turnstile/</link>
                <guid isPermaLink="false">685594145aea0dba325c37e1</guid>
                
                    <category>
                        <![CDATA[ supabase ss ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Astro ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ supabase ]]>
                    </category>
                
                    <category>
                        <![CDATA[ supabase auth ]]>
                    </category>
                
                    <category>
                        <![CDATA[ magic links ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloudflare Turnstile ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SSR ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Fatuma Abdullahi ]]>
                </dc:creator>
                <pubDate>Fri, 20 Jun 2025 17:02:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1750438909287/d36c0c01-e779-4eea-aa41-b797fcbb05f6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this guide, you'll build a full server-side rendered (SSR) authentication system using Astro, Supabase, and Cloudflare Turnstile to protect against bots.</p>
<p>By the end, you'll have a fully functional authentication system with Astro actions, magic link authentication using Supabase, bot protection via Cloudflare Turnstile, protected routes and middleware, and secure session management.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-understanding-the-technologies">Understanding the Technologies</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-is-astro">What is Astro?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-are-astro-actions">What are Astro Actions?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-supabase">What is Supabase?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-cloudflare-turnstile">What is Cloudflare Turnstile?</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-understanding-ssr-authentication">Understanding SSR Authentication</a></p>
<ul>
<li><a class="post-section-overview" href="#heading-ssr-vs-spa-authentication">SSR vs. SPA Authentication</a></li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-why-protect-auth-forms">Why Protect Auth Forms?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-part-1-how-to-set-up-the-backend">Part 1: How to Set Up the Backend</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-set-up-supabase-backend">Set Up Supabase Backend</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-set-up-cloudflare-turnstile">Set Up Cloudflare Turnstile</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-2-how-to-set-up-the-frontend">Part 2: How to Set Up the Frontend</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-create-the-astro-project">Create the Astro Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-configure-astro-for-ssr">Configure Astro for SSR</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-install-supabase-dependencies">Install Supabase Dependencies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-configure-environment-variables">Configure Environment Variables</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-3-how-to-set-up-supabase-ssr">Part 3: How to Set Up Supabase SSR</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-create-the-supabase-client">Create the Supabase Client</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-middleware-for-route-protection">Create Middleware for Route Protection</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-4-how-to-build-the-user-interface">Part 4: How to Build the User Interface</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-update-the-layout">Update the Layout</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-the-sign-in-page">Create the Sign-In Page</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-the-protected-page">Create the Protected Page</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-5-how-to-set-up-astro-actions">Part 5: How to Set Up Astro Actions</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-create-the-authentication-actions">Create the Authentication Actions</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-create-the-code-exchange-api-route">Create the Code Exchange API Route</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-part-6-how-to-test-your-application">Part 6: How to Test Your Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-notes-and-additional-resources">Notes and Additional Resources</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-useful-documentation">Useful Documentation</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-complete-code-repository">Complete Code Repository</a></p>
</li>
</ul>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This tutorial assumes you are familiar with:</p>
<ul>
<li><p>Web development frameworks</p>
</li>
<li><p><a target="_blank" href="https://www.freecodecamp.org/news/set-up-authentication-in-apps-with-supabase/">Basic authentication flows</a></p>
</li>
<li><p>Basic Backend-as-a-Service (BaaS) concepts</p>
</li>
</ul>
<h2 id="heading-understanding-the-technologies">Understanding the Technologies</h2>
<h3 id="heading-what-is-astro">What is Astro?</h3>
<p><a target="_blank" href="https://docs.astro.build/en/getting-started/">Astro</a> is a UI-agnostic web framework that renders <a target="_blank" href="https://docs.astro.build/en/concepts/why-astro/#server-first">server-first</a> by default. It <a target="_blank" href="https://docs.astro.build/en/guides/integrations-guide/#official-integrations">can be used with any UI framework</a>, including <a target="_blank" href="https://docs.astro.build/en/guides/client-side-scripts/">Astro client components</a>.</p>
<h3 id="heading-what-are-astro-actions">What are Astro Actions?</h3>
<p><a target="_blank" href="https://docs.astro.build/en/guides/actions/">Astro actions</a> allow you to write server-side functions that can be called without explicitly setting up API routes. They provide many useful utilities that simplify the process of running server logic and can be called from both client and server environments.</p>
<h3 id="heading-what-is-supabase">What is Supabase?</h3>
<p><a target="_blank" href="https://supabase.com/docs">Supabase</a> is an open-source Backend-as-a-Service that builds upon <a target="_blank" href="https://www.postgresql.org/docs/">Postgres</a>. It provides key features such as authentication, real-time capabilities, edge functions, storage, and more. Supabase offers both a hosted version for easy scaling and a self-hostable version for full control.</p>
<h3 id="heading-what-is-cloudflare-turnstile">What is Cloudflare Turnstile?</h3>
<p>Turnstile is <a target="_blank" href="https://www.cloudflare.com/en-gb/application-services/products/turnstile/">Cloudflare's replacement for CAPTCHAs</a>, which are visual puzzles used to differentiate between genuine users and bots. Unlike traditional CAPTCHAs, which are visually clunky, annoying, and <a target="_blank" href="https://blog.cloudflare.com/turnstile-ga/">sometimes difficult to solve</a>, Turnstile detects malicious activity without requiring users to solve puzzles, while providing a better user experience.</p>
<h2 id="heading-understanding-ssr-authentication">Understanding SSR Authentication</h2>
<p>Server-side rendered (SSR) auth refers to handling authentication on the server using a <a target="_blank" href="https://www.freecodecamp.org/news/set-up-authentication-in-apps-with-supabase/#how-does-authentication-work">cookie-based authentication method</a>.</p>
<p>The flow works as follows:</p>
<ol>
<li><p>The server creates a session and stores a session ID in a cookie sent to the client</p>
</li>
<li><p>The browser receives the cookie and automatically includes it in future requests</p>
</li>
<li><p>The server uses the cookie to determine if the user is authenticated</p>
</li>
</ol>
<p>Since browsers cannot modify HTTP-only cookies and servers cannot access local storage, SSR authentication requires careful management to prevent security risks such as session hijacking and stale sessions.</p>
<h3 id="heading-ssr-vs-spa-authentication">SSR vs. SPA Authentication</h3>
<p>Single-Page Applications (SPAs), like traditional React apps, handle authentication on the client side because they don't have direct access to a server. SPAs typically use JWTs stored in local storage, cookies, or session storage, sending these tokens in HTTP headers when communicating with servers.</p>
<div class="embed-wrapper">
        <iframe width="560" height="315" src="https://www.youtube.com/embed/HdE3dk8VkRU" 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> </p>
<h2 id="heading-why-protect-auth-forms">Why Protect Auth Forms?</h2>
<p>Authentication protects sensitive resources from unauthorized access, making auth forms primary targets for bots and malicious actors. Taking extra precautions is important for maintaining security.</p>
<h2 id="heading-part-1-how-to-set-up-the-backend">Part 1: How to Set Up the Backend</h2>
<h3 id="heading-set-up-supabase-backend">Set Up Supabase Backend</h3>
<p>First, you'll need <a target="_blank" href="https://supabase.com/dashboard/">a Supabase account</a>. Create a project, then:</p>
<ol>
<li><p>Go to the Authentication tab in the sidebar</p>
</li>
<li><p>Click the Sign In / Up tab under Configuration</p>
</li>
<li><p>Enable user sign-ups</p>
</li>
<li><p>Scroll down to Auth Providers and enable email (disable email confirmation for this tutorial)</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742054137964/a379192b-4eaf-491f-bcf4-a0e1e0deef94.png" alt="Supabase authentication configuration interface showing user signup options and email provider enabled" width="2480" height="1448" loading="lazy"></p>
<h3 id="heading-set-up-cloudflare-turnstile">Set Up Cloudflare Turnstile</h3>
<ol>
<li><p><a target="_blank" href="https://dash.cloudflare.com/login">Log in or register for a Cloudflare account</a></p>
</li>
<li><p>Click the Turnstile tab in the sidebar</p>
</li>
<li><p>Click the "Add widget" button</p>
</li>
<li><p>Name your widget and add "localhost" as the hostname</p>
</li>
<li><p>Leave all other settings as default, and create the widget</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750260766060/95ec02e5-8ee7-4438-a66c-76866ec068c1.png" alt="Cloudflare Turnstile widget creation interface" width="2200" height="1796" loading="lazy"></p>
<p>After creating the widget, copy the secret key and add it to your Supabase dashboard:</p>
<ol>
<li><p>Go back to Supabase Authentication settings</p>
</li>
<li><p>Navigate to the Auth Protection tab under Configuration</p>
</li>
<li><p>Turn on Captcha protection</p>
</li>
<li><p>Choose Cloudflare as the provider</p>
</li>
<li><p>Paste your secret key</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750260776990/56ef5fc1-3321-45f0-ab9a-878679a08e88.png" alt="Supabase Attack Protection settings with Turnstile configuration" width="2302" height="986" loading="lazy"></p>
<h2 id="heading-part-2-how-to-set-up-the-frontend">Part 2: How to Set Up the Frontend</h2>
<h3 id="heading-create-the-astro-project">Create the Astro Project</h3>
<p>Next, you will need to create an Astro project. Open your preferred IDE or Text editor’s integrated terminal and run the following command to scaffold an Astro project in a folder named “ssr-auth.” Feel free to use any name you like.</p>
<pre><code class="lang-bash">npm create astro@latest ssr-auth
</code></pre>
<p>Follow the provided prompts and choose a basic template to start with. When it’s done, change into the folder, then run <code>npm install</code> to install dependencies, followed by <code>npm run dev</code> to start the server, and your site will be available at <a target="_blank" href="http://localhost:4321"><code>localhost:4321</code></a>.</p>
<h3 id="heading-configure-astro-for-ssr">Configure Astro for SSR</h3>
<p>Set Astro to run in SSR mode by adding <code>output: "server",</code> to the <code>defineConfig</code> function found in the <code>astro.config.mjs</code> file at the root of the folder.</p>
<p>Next, <a target="_blank" href="https://docs.astro.build/en/guides/integrations-guide/node/">add an adapter</a> to create a server runtime. For this, use the Node.js adapter by running this command in a terminal: <code>npx astro add node</code>. This will add it and automatically make all relevant changes.</p>
<p>Finally, add Tailwind for styling. Run this command in a terminal window: <code>npx astro add tailwind</code>. Follow the prompts, and it will make any changes necessary.</p>
<p>At this stage, your <code>astro.config.mjs</code> should look like this:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// @ts-check</span>
<span class="hljs-keyword">import</span> { defineConfig } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro/config"</span>;
<span class="hljs-keyword">import</span> node <span class="hljs-keyword">from</span> <span class="hljs-string">"@astrojs/node"</span>;
<span class="hljs-keyword">import</span> tailwindcss <span class="hljs-keyword">from</span> <span class="hljs-string">"@tailwindcss/vite"</span>;

<span class="hljs-comment">// https://astro.build/config</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> defineConfig({
  output: <span class="hljs-string">"server"</span>,
  adapter: node({
    mode: <span class="hljs-string">"standalone"</span>,
  }),
  vite: {
    plugins: [tailwindcss()],
  },
});
</code></pre>
<h3 id="heading-install-supabase-dependencies">Install Supabase Dependencies</h3>
<p>You can do this by running the following command:</p>
<pre><code class="lang-bash">npm install @supabase/supabase-js @supabase/ssr
</code></pre>
<h3 id="heading-configure-environment-variables">Configure Environment Variables</h3>
<p>Create a <code>.env</code> file in the project root and add the following. Remember to replace with your actual credentials:</p>
<pre><code class="lang-bash">SUPABASE_URL=&lt;YOUR_URL&gt;
SUPABASE_ANON_KEY=&lt;YOUR_ANON_KEY&gt;
TURNSTILE_SITE_KEY=&lt;YOUR_TURNSTILE_SITE_KEY&gt;
</code></pre>
<p>You can get the Supabase values from the dashboard:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1742054292788/8aeec326-259c-49bd-a6f8-b885e9a9e6ea.png" alt="Supabase project connection interface showing environment variables" width="2132" height="802" loading="lazy"></p>
<p><strong>💡Note:</strong> In Astro, environment variables accessed on the client side must be prefixed with 'PUBLIC'. But since we're using Astro actions that run on the server, the prefix is not required.</p>
<h2 id="heading-part-3-how-to-set-up-supabase-ssr">Part 3: How to Set Up Supabase SSR</h2>
<h3 id="heading-create-the-supabase-client">Create the Supabase Client</h3>
<p>Create <code>src/lib/supabase.ts</code>:</p>
<pre><code class="lang-typescript">
<span class="hljs-keyword">import</span> { createServerClient, parseCookieHeader } <span class="hljs-keyword">from</span> <span class="hljs-string">"@supabase/ssr"</span>;
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { AstroCookies } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createClient</span>(<span class="hljs-params">{
    request,
    cookies,
}: {
    request: Request;
    cookies: AstroCookies;
}</span>) </span>{
    <span class="hljs-keyword">const</span> cookieHeader = request.headers.get(<span class="hljs-string">"Cookie"</span>) || <span class="hljs-string">""</span>;

    <span class="hljs-keyword">return</span> createServerClient(
        <span class="hljs-keyword">import</span>.meta.env.SUPABASE_URL,
        <span class="hljs-keyword">import</span>.meta.env.SUPABASE_ANON_KEY,
        {
            cookies: {
                getAll() {
                    <span class="hljs-keyword">const</span> cookies = parseCookieHeader(cookieHeader);
                    <span class="hljs-keyword">return</span> cookies.map(<span class="hljs-function">(<span class="hljs-params">{ name, value }</span>) =&gt;</span> ({
                        name,
                        value: value ?? <span class="hljs-string">""</span>,
                    }));
                },
                setAll(cookiesToSet) {
                    cookiesToSet.forEach(<span class="hljs-function">(<span class="hljs-params">{ name, value, options }</span>) =&gt;</span>
                        cookies.set(name, value, options)
                    );
                },
            },
        }
    );
}
</code></pre>
<p>This sets up Supabase to handle <a target="_blank" href="https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&amp;framework=astro&amp;queryGroups=environment&amp;environment=astro-browser">cookies in a server-rendered application</a> and exports a function that takes the request and cookies object as input. The function is set up like this because Astro has three ways to access request and cookie information:</p>
<ul>
<li><p>Through Astro’s global object, which is only available on Astro pages.</p>
</li>
<li><p>Through <code>AstroAPIContext</code> object, which is only available in Astro actions.</p>
</li>
<li><p>Through <code>APIContext</code> which is a subset of the global object and is available through API routes and middleware.</p>
</li>
</ul>
<p>So the <code>createClient</code> function accepts the <code>request</code> and <code>cookies</code> objects separately to make it flexible and applicable in the various contexts in which it may be used.</p>
<h3 id="heading-create-middleware-for-route-protection">Create Middleware for Route Protection</h3>
<p>Next, create a <code>middleware.ts</code> file in the <code>src</code> folder and paste this into it:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { defineMiddleware } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:middleware"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"./lib/supabase"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> onRequest = defineMiddleware(<span class="hljs-keyword">async</span> (context, next) =&gt; {
    <span class="hljs-keyword">const</span> { pathname } = context.url;

    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Middleware executing for path:"</span>, pathname);

    <span class="hljs-keyword">const</span> supabase = createClient({
        request: context.request,
        cookies: context.cookies,
    });

    <span class="hljs-keyword">if</span> (pathname === <span class="hljs-string">"/protected"</span>) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Checking auth for protected route"</span>);

        <span class="hljs-keyword">const</span> { data } = <span class="hljs-keyword">await</span> supabase.auth.getUser();

        <span class="hljs-comment">// If no user, redirect to index</span>
        <span class="hljs-keyword">if</span> (!data.user) {
            <span class="hljs-keyword">return</span> context.redirect(<span class="hljs-string">"/"</span>);
        }
    }

    <span class="hljs-keyword">return</span> next();
});
</code></pre>
<p>This middleware checks for an active user when accessing the protected route and redirects unauthenticated users to the index page.</p>
<h2 id="heading-part-4-how-to-build-the-user-interface">Part 4: How to Build the User Interface</h2>
<h3 id="heading-update-the-layout">Update the Layout</h3>
<p>First, update <code>src/layouts/Layout.astro</code> to include the Turnstile script. Add this just above the closing <code>&lt;/head&gt;</code> tag:</p>
<pre><code class="lang-typescript">&lt;script
    src=<span class="hljs-string">"https://challenges.cloudflare.com/turnstile/v0/api.js"</span>
    <span class="hljs-keyword">async</span>
    defer&gt;
&lt;/script&gt;
</code></pre>
<h3 id="heading-create-the-sign-in-page">Create the Sign-In Page</h3>
<p>Replace the contents of <code>src/pages/index.astro</code>:</p>
<pre><code class="lang-typescript">---
<span class="hljs-keyword">import</span> Layout <span class="hljs-keyword">from</span> <span class="hljs-string">"../layouts/Layout.astro"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"../lib/supabase"</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">"../styles/global.css"</span>;

<span class="hljs-keyword">const</span> supabase = createClient({
    request: Astro.request,
    cookies: Astro.cookies,
});

<span class="hljs-keyword">const</span> { data } = <span class="hljs-keyword">await</span> supabase.auth.getUser();

<span class="hljs-keyword">if</span> (data.user) {
    <span class="hljs-keyword">return</span> Astro.redirect(<span class="hljs-string">"/protected"</span>);
}

<span class="hljs-keyword">const</span> apiKey = <span class="hljs-keyword">import</span>.meta.env.TURNSTILE_SITE_KEY;
---

&lt;Layout&gt;
    &lt;section <span class="hljs-keyword">class</span>=<span class="hljs-string">"flex flex-col items-center justify-center m-30"</span>&gt;
        &lt;h1 <span class="hljs-keyword">class</span>=<span class="hljs-string">"text-4xl text-left font-bold mb-12"</span>&gt;Sign In to Your Account&lt;/h1&gt;
        &lt;form id=<span class="hljs-string">"signin-form"</span> <span class="hljs-keyword">class</span>=<span class="hljs-string">"flex flex-col gap-2 w-1/2"</span>&gt;
            &lt;label <span class="hljs-keyword">for</span>=<span class="hljs-string">"email"</span> <span class="hljs-keyword">class</span>=<span class="hljs-string">""</span>&gt;Enter your email&lt;/label&gt;
            &lt;input
                <span class="hljs-keyword">type</span>=<span class="hljs-string">"email"</span>
                name=<span class="hljs-string">"email"</span>
                id=<span class="hljs-string">"email"</span>
                placeholder=<span class="hljs-string">"youremail@example.com"</span>
                <span class="hljs-keyword">class</span>=<span class="hljs-string">"border border-gray-500 rounded-md p-2"</span>
                required
            /&gt;
            &lt;div <span class="hljs-keyword">class</span>=<span class="hljs-string">"cf-turnstile"</span> data-sitekey={apiKey}&gt;&lt;/div&gt;
            &lt;button
                <span class="hljs-keyword">type</span>=<span class="hljs-string">"submit"</span>
                id=<span class="hljs-string">"sign-in"</span>
                <span class="hljs-keyword">class</span>=<span class="hljs-string">"bg-gray-600 hover:bg-gray-700 p-2 rounded-md text-white font-bold w-full cursor-pointer disabled:bg-gray-500 disabled:hover:bg-gray-500 disabled:cursor-not-allowed"</span>
                &gt;Sign In&lt;/button
            &gt;
        &lt;/form&gt;
    &lt;/section&gt;
&lt;/Layout&gt;
</code></pre>
<p>Here, the frontmatter creates a Supabase server client and then uses it to check if we have an active user. It redirects based on this information. This works because the front matter runs on the server side, and the project is set to server output.</p>
<p>The template displays a simple form with an email input. To complete it, add this below the closing <code>&lt;/Layout&gt;</code> tag:</p>
<pre><code class="lang-typescript">
&lt;script&gt;
    <span class="hljs-keyword">import</span> { actions } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:actions"</span>;

    <span class="hljs-keyword">declare</span> <span class="hljs-built_in">global</span> {
        <span class="hljs-keyword">interface</span> Window {
            turnstile?: {
                reset: <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</span>;
            };
        }
    }

    <span class="hljs-keyword">const</span> signInForm = <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">"#signin-form"</span>) <span class="hljs-keyword">as</span> HTMLFormElement;
    <span class="hljs-keyword">const</span> formSubmitBtn = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"sign-in"</span>) <span class="hljs-keyword">as</span> HTMLButtonElement;

    signInForm?.addEventListener(<span class="hljs-string">"submit"</span>, <span class="hljs-keyword">async</span> (e) =&gt; {
        e.preventDefault();
        formSubmitBtn.disabled = <span class="hljs-literal">true</span>;
        formSubmitBtn.textContent = <span class="hljs-string">"Signing in..."</span>;

        <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">const</span> turnstileToken = (
                <span class="hljs-built_in">document</span>.querySelector(
                    <span class="hljs-string">"[name='cf-turnstile-response']"</span>
                ) <span class="hljs-keyword">as</span> HTMLInputElement
            )?.value;

            <span class="hljs-keyword">if</span> (!turnstileToken) {
                <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">"verification_missing"</span>);
            }

            <span class="hljs-keyword">const</span> formData = <span class="hljs-keyword">new</span> FormData(signInForm);
            formData.append(<span class="hljs-string">"captchaToken"</span>, turnstileToken);

            <span class="hljs-keyword">const</span> results = <span class="hljs-keyword">await</span> actions.signIn(formData);

            <span class="hljs-keyword">if</span> (!results.data?.success) {
                <span class="hljs-keyword">if</span> (results.data?.message?.includes(<span class="hljs-string">"captcha protection"</span>)) {
                    alert(<span class="hljs-string">"Verification failed. Please try again."</span>);
                    <span class="hljs-keyword">if</span> (<span class="hljs-built_in">window</span>.turnstile) {
                        <span class="hljs-built_in">window</span>.turnstile.reset();
                    }
                    formSubmitBtn.disabled = <span class="hljs-literal">false</span>;
                    formSubmitBtn.textContent = <span class="hljs-string">"Sign In"</span>;
                    <span class="hljs-keyword">return</span>;
                } <span class="hljs-keyword">else</span> {
                    alert(<span class="hljs-string">"Oops! Could not sign in. Please try again"</span>);
                    formSubmitBtn.disabled = <span class="hljs-literal">false</span>;
                    formSubmitBtn.textContent = <span class="hljs-string">"Sign In"</span>;
                    <span class="hljs-keyword">return</span>;
                }
            }

            formSubmitBtn.textContent = <span class="hljs-string">"Sign In"</span>;
            alert(<span class="hljs-string">"Please check your email to sign in"</span>);
        } <span class="hljs-keyword">catch</span> (error) {
            <span class="hljs-keyword">if</span> (<span class="hljs-built_in">window</span>.turnstile) {
                <span class="hljs-built_in">window</span>.turnstile.reset();
            }
            formSubmitBtn.disabled = <span class="hljs-literal">false</span>;
            formSubmitBtn.textContent = <span class="hljs-string">"Sign In"</span>;
            <span class="hljs-built_in">console</span>.log(error);
            alert(<span class="hljs-string">"Something went wrong. Please try again"</span>);
        }
    });
&lt;/script&gt;
</code></pre>
<p>This adds some vanilla JavaScript that calls the <code>SignIn</code> Upon form submission. This action provides user feedback through alerts and manages the button’s text and disabled state. This effectively adds client-side interactivity to the page.</p>
<h3 id="heading-create-the-protected-page">Create the Protected Page</h3>
<p>Create <code>src/pages/protected.astro</code>:</p>
<pre><code class="lang-typescript">---
<span class="hljs-keyword">import</span> Layout <span class="hljs-keyword">from</span> <span class="hljs-string">"../layouts/Layout.astro"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"../lib/supabase"</span>;
<span class="hljs-keyword">import</span> <span class="hljs-string">"../styles/global.css"</span>;

<span class="hljs-keyword">const</span> supabase = createClient({
    request: Astro.request,
    cookies: Astro.cookies,
});

<span class="hljs-keyword">const</span> { data } = <span class="hljs-keyword">await</span> supabase.auth.getUser();
---

&lt;Layout&gt;
    &lt;section <span class="hljs-keyword">class</span>=<span class="hljs-string">"flex flex-col items-center justify-center m-30"</span>&gt;
        &lt;h1 <span class="hljs-keyword">class</span>=<span class="hljs-string">"text-4xl text-left font-bold mb-12"</span>&gt;You are logged <span class="hljs-keyword">in</span>!&lt;/h1&gt;
        &lt;p <span class="hljs-keyword">class</span>=<span class="hljs-string">"mb-6"</span>&gt;Your user Id: {data.user?.id}&lt;/p&gt;
        &lt;button
            id=<span class="hljs-string">"sign-out"</span>
            <span class="hljs-keyword">class</span>=<span class="hljs-string">"bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-md text-white font-bold cursor-pointer disabled:bg-gray-500 disabled:hover:bg-gray-500 disabled:cursor-not-allowed"</span>
            &gt;Sign Out&lt;/button
        &gt;
    &lt;/section&gt;
&lt;/Layout&gt;

&lt;script&gt;
    <span class="hljs-keyword">import</span> { actions } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:actions"</span>;
    <span class="hljs-keyword">const</span> signOutBtn = <span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">"sign-out"</span>) <span class="hljs-keyword">as</span> HTMLButtonElement;

    signOutBtn?.addEventListener(<span class="hljs-string">"click"</span>, <span class="hljs-keyword">async</span> (e) =&gt; {
        e.preventDefault();
        signOutBtn!.disabled = <span class="hljs-literal">true</span>;
        signOutBtn!.textContent = <span class="hljs-string">"Signing out..."</span>;

        <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">const</span> results = <span class="hljs-keyword">await</span> actions.signOut();

            <span class="hljs-keyword">if</span> (!results.data?.success) {
                signOutBtn!.disabled = <span class="hljs-literal">false</span>;
                signOutBtn!.textContent = <span class="hljs-string">"Sign Out"</span>;
                <span class="hljs-keyword">return</span> alert(<span class="hljs-string">"Oops! Could not sign Out. Please try again"</span>);
            }
            <span class="hljs-keyword">return</span> <span class="hljs-built_in">window</span>.location.reload();
        } <span class="hljs-keyword">catch</span> (error) {
            signOutBtn.disabled = <span class="hljs-literal">false</span>;
            signOutBtn.textContent = <span class="hljs-string">"Sign Out"</span>;
            <span class="hljs-built_in">console</span>.log(error);
            <span class="hljs-keyword">return</span> alert(<span class="hljs-string">"Something went wrong. Please try again"</span>);
        }
    });
&lt;/script&gt;
</code></pre>
<p>This page retrieves the user data server-side in the front matter and displays it in the template, along with a sign-out button.</p>
<p>The JavaScript in the <code>script</code> tags handle calling the sign-out action, user feedback, and button state, as in the <code>index.astro</code> page.</p>
<h2 id="heading-part-5-how-to-set-up-astro-actions">Part 5: How to Set Up Astro Actions</h2>
<h3 id="heading-create-the-authentication-actions">Create the Authentication Actions</h3>
<p>Finally, add an <code>actions</code> folder in the <code>src</code> folder and create an <code>index.ts</code> file to hold our logic. Paste the following into it:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { defineAction, <span class="hljs-keyword">type</span> ActionAPIContext } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:actions"</span>;
<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro:schema"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"../lib/supabase"</span>;

<span class="hljs-keyword">const</span> emailSignUp = <span class="hljs-keyword">async</span> (
    {
        email,
        captchaToken,
    }: {
        email: <span class="hljs-built_in">string</span>;
        captchaToken: <span class="hljs-built_in">string</span>;
    },
    context: ActionAPIContext
) =&gt; {
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Sign up action"</span>);
    <span class="hljs-keyword">try</span> {
        <span class="hljs-keyword">const</span> supabase = createClient({
            request: context.request,
            cookies: context.cookies,
        });

        <span class="hljs-keyword">const</span> { data, error } = <span class="hljs-keyword">await</span> supabase.auth.signInWithOtp({
            email,
            options: {
                captchaToken,
                emailRedirectTo: <span class="hljs-string">"http://localhost:4321/api/exchange"</span>,
            },
        });

        <span class="hljs-keyword">if</span> (error) {
            <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Sign up error"</span>, error);
            <span class="hljs-keyword">return</span> {
                success: <span class="hljs-literal">false</span>,
                message: error.message,
            };
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Sign up success"</span>, data);
            <span class="hljs-keyword">return</span> {
                success: <span class="hljs-literal">true</span>,
                message: <span class="hljs-string">"Successfully logged in"</span>,
            };
        }
    } <span class="hljs-keyword">catch</span> (err) {
        <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"SignUp action other error"</span>, err);
        <span class="hljs-keyword">return</span> {
            success: <span class="hljs-literal">false</span>,
            message: <span class="hljs-string">"Unexpected error"</span>,
        };
    }
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> server = {
    signIn: defineAction({
        accept: <span class="hljs-string">"form"</span>,
        input: z.object({
            email: z.string().email(),
            captchaToken: z.string(),
        }),
        handler: <span class="hljs-keyword">async</span> (input, context) =&gt; {
            <span class="hljs-keyword">return</span> emailSignUp(input, context);
        },
    }),
    signOut: defineAction({
        handler: <span class="hljs-keyword">async</span> (_, context) =&gt; {
            <span class="hljs-keyword">const</span> supabase = createClient({
                request: context.request,
                cookies: context.cookies,
            });
            <span class="hljs-keyword">const</span> { error } = <span class="hljs-keyword">await</span> supabase.auth.signOut();
            <span class="hljs-keyword">if</span> (error) {
                <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Sign out error"</span>, error);
                <span class="hljs-keyword">return</span> {
                    success: <span class="hljs-literal">false</span>,
                    message: error.message,
                };
            }
            <span class="hljs-keyword">return</span> {
                success: <span class="hljs-literal">true</span>,
                message: <span class="hljs-string">"Successfully signed out"</span>,
            };
        },
    }),
};
</code></pre>
<p>This action handles both sign-in and sign-out methods. A Supabase server instance is created during the sign-in method, and the magic link method is used for sign-in. It passes a redirect URL, which we have yet to create, and handles any errors that may occur.</p>
<p>It also passes the token verification, allowing Supabase to perform verification on our behalf, eliminating the need to call <a target="_blank" href="https://developers.cloudflare.com/turnstile/get-started/server-side-validation/">Cloudflare’s verify APIs</a> directly.</p>
<p>The sign-out method calls Supabase’s sign-out method and handles any potential errors.</p>
<p>The redirect URL refers to an API route that exchanges the code from the email Supabase sends for a session that Supabase handles.</p>
<h3 id="heading-create-the-code-exchange-api-route">Create the Code Exchange API Route</h3>
<p>Create <code>src/pages/api/exchange.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { APIRoute } <span class="hljs-keyword">from</span> <span class="hljs-string">"astro"</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"../../lib/supabase"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> GET: APIRoute = <span class="hljs-keyword">async</span> ({ request, cookies, redirect }) =&gt; {
    <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> URL(request.url);
    <span class="hljs-keyword">const</span> code = url.searchParams.get(<span class="hljs-string">"code"</span>);

    <span class="hljs-keyword">if</span> (!code) {
        <span class="hljs-keyword">return</span> redirect(<span class="hljs-string">"/"</span>);
    }

    <span class="hljs-keyword">const</span> supabase = createClient({ request, cookies });
    <span class="hljs-keyword">const</span> { error } = <span class="hljs-keyword">await</span> supabase.auth.exchangeCodeForSession(code);

    <span class="hljs-keyword">if</span> (error) {
        <span class="hljs-built_in">console</span>.error(<span class="hljs-string">"Error exchanging code for session:"</span>, error);
        <span class="hljs-keyword">return</span> redirect(<span class="hljs-string">"/404"</span>);
    }

    <span class="hljs-keyword">return</span> redirect(<span class="hljs-string">"/protected"</span>);
};
</code></pre>
<p>This grabs the code from the URL in the magic link sent, creates a server client, and calls the <code>exchangeCodeForSession</code> method with the code. It handles any error by redirecting to Astro’s built-in not-found page.</p>
<p>Otherwise, it will redirect to the protected page as Supabase handles the session implementation details.</p>
<h2 id="heading-part-6-how-to-test-your-application">Part 6: How to Test Your Application</h2>
<p>Start your development server: <code>npm run dev</code></p>
<p>Visit the provided localhost URL. You should see the sign-in page with the Turnstile widget:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750267075336/66ad5f39-67c6-458a-96ea-4dfe1123b015.png" alt="Sign-in page with Turnstile verification and email input field" width="2356" height="956" loading="lazy"></p>
<p>If you try to access the <code>/protected</code> page, it will redirect you back to this view until you sign in. Now, sign in, and you should get an email with a link that will redirect you to the <code>/protected</code> page. This is what you should see:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750335131827/f85cde2f-f9bb-46b0-a09e-6ae6456cd49f.png" alt="Text reads: &quot;You are logged in!&quot; with a field labeled &quot;Your user Id&quot; and a &quot;Sign Out&quot; button below." width="1200" height="502" loading="lazy"></p>
<p>And with that, you've successfully built a comprehensive auth system that leverages Astro actions, Supabase auth, and Cloudflare Turnstile's bot protection. This setup provides a secure, user-friendly authentication experience while protecting your application from malicious actors.</p>
<h2 id="heading-notes-and-additional-resources">Notes and Additional Resources</h2>
<h3 id="heading-useful-documentation">Useful Documentation</h3>
<ul>
<li><p><a target="_blank" href="https://supabase.com/docs/guides/auth/server-side/advanced-guide">Supabase's advanced guide to SSR</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/supabase/ssr">Supabase SSR package</a></p>
</li>
<li><p><a target="_blank" href="https://docs.astro.build/en/reference/api-reference/#cookies">Astro Cookies documentation</a></p>
</li>
<li><p><a target="_blank" href="https://supabase.com/docs/guides/auth/sessions/pkce-flow">Supabase PKCE flow documentation</a></p>
</li>
<li><p><a target="_blank" href="https://docs.astro.build/en/guides/actions/">Astro Actions documentation</a></p>
</li>
<li><p><a target="_blank" href="https://developers.cloudflare.com/turnstile/get-started/">Get started with Turnstile</a></p>
</li>
</ul>
<h3 id="heading-complete-code-repository">Complete Code Repository</h3>
<p>The complete code for this project is available on GitHub:</p>
<ul>
<li><p><a target="_blank" href="https://github.com/FatumaA/supa-ssr">Base authentication setup</a></p>
</li>
<li><p><a target="_blank" href="https://github.com/FatumaA/supa-ssr/tree/add-cloudflare">With Cloudflare Turnstile</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Speed Up Website Loading by Removing Extra Bits and Bytes ]]>
                </title>
                <description>
                    <![CDATA[ Let’s start with an interesting fact: according to research done by Akamai, a 1-second delay in loading a website’s page can decrease the conversion rate by 7%. We are currently living in a fast-paced world, where time is money for everyone. People e... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/speed-up-website-loading/</link>
                <guid isPermaLink="false">67bca6f616cf4617f656a33f</guid>
                
                    <category>
                        <![CDATA[ code optimization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ server ]]>
                    </category>
                
                    <category>
                        <![CDATA[ hosting ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google PageSpeed ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ performance ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Alex Tray ]]>
                </dc:creator>
                <pubDate>Mon, 24 Feb 2025 17:05:58 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1740094347867/d1097d7b-776f-4228-8088-7726b827271f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Let’s start with an interesting fact: according to research done by <a target="_blank" href="https://www.akamai.com/newsroom/press-release/akamai-releases-spring-2017-state-of-online-retail-performance-report">Akamai</a>, a 1-second delay in loading a website’s page can decrease the conversion rate by 7%.</p>
<p>We are currently living in a fast-paced world, where time is money for everyone. People expect their favorite websites to load lightning-fast. A slow loading speed will not only make them go to the competitor but will also hurt the <a target="_blank" href="https://www.freecodecamp.org/news/how-to-use-on-page-seo-techniques-to-rank-on-the-first-page/">website's ranking</a> in the SERP.</p>
<p>But the main question is, who’s the culprit? Those extra bits and bytes that almost every site contains. These are unnecessary code files, unoptimized images, and many more. But by following the right approach, you can easily strip away these inefficiencies and achieve excellent loading speed.</p>
<p>In this article, I will be discussing that approach in detail, so stick around with me till the very end.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-why-does-loading-speed-matter">Why Does Loading Speed Matter?</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-google-ranking-factor">Google Ranking Factor</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-impact-on-user-experience">Impact on User Experience</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-negative-brand-perception">Negative Brand Perception</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-retaining-mobile-users">Retaining Mobile Users</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-remove-extra-bits-amp-bytes-from-the-website-different-strategies">How to Remove Extra Bits &amp; Bytes from the Website – Different Strategies</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-perform-code-optimization">Perform Code Optimization</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-image-amp-media-optimization">Image &amp; Media Optimization</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-manage-plugins-amp-scripts">Manage Plugins &amp; Scripts</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-server-amp-hosting-upgrades">Server &amp; Hosting Upgrades</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-tools-that-you-can-use-to-streamline-the-process">Tools That You Can Use to Streamline the Process</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-minifierhttpswwwminifierorg">Minifier</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-tinypnghttpstinypngcom">TinyPNG</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-png-to-webp-converterhttpscloudconvertcompng-to-webp">PNG to WebP Converter</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-google-pagespeed-insighthttpspagespeedwebdev">Google PageSpeed Insight</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-cloudflarehttpswwwcloudflarecom">Cloudflare</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-why-does-loading-speed-matter">Why Does Loading Speed Matter?</h2>
<p>There are several reasons why the loading speed of a website is considered essential. Here are some of the major ones.</p>
<ol>
<li><h3 id="heading-google-ranking-factor">Google Ranking Factor:</h3>
</li>
</ol>
<p>Website loading speed is a confirmed ranking factor. This means that search engines like Google definitely consider the loading time when evaluating a website’s quality. Usually, the <a target="_blank" href="https://sematext.com/glossary/page-load-time/">ideal loading speed</a> is between 0 and 2 seconds. However, 3 seconds is also sometimes acceptable.</p>
<p>In case your site does not fulfill this criteria, then there is a high probability that it may receive a penalty from Google. This will result in lower rankings in the targeted niche – which no webmaster or business wants.</p>
<ol start="2">
<li><h3 id="heading-impact-on-user-experience">Impact on User Experience:</h3>
</li>
</ol>
<p>A slow loading speed is capable of single-handedly destroying the entire user experience. When the website does not load quickly in front of the visitor, they may close it and move on to another site to find the required information, product, or service.</p>
<p>This will decrease the number of user engagements and increase the overall bounce rate of the site. And a high bounce rate increases the chances of facing a penalty from Google.</p>
<ol start="3">
<li><h3 id="heading-negative-brand-perception">Negative Brand Perception:</h3>
</li>
</ol>
<p>For online businesses or brands, their authority and image are everything. When their official site takes too much time to load, it ultimately damages the brand’s perception or credibility in their minds. They will think about how you can deliver a top-notch service or product when you aren’t able to properly manage a website.</p>
<p>This negative impression will not only reduce customer engagement but also conversions.</p>
<ol start="4">
<li><h3 id="heading-retaining-mobile-users">Retaining Mobile Users:</h3>
</li>
</ol>
<p>Mobile contributes to <a target="_blank" href="https://www.mobiloud.com/blog/what-percentage-of-internet-traffic-is-mobile">58% of the global internet traffic</a>. It is also true mobile networks often have slow internet speed issues as compared to Wi-Fi. This can be especially true for people living in rural areas. So, that’s why you should always prioritize loading speed to efficiently retain mobile users.</p>
<h2 id="heading-how-to-remove-extra-bits-amp-bytes-from-the-website-different-strategies">How to Remove Extra Bits &amp; Bytes from the Website – Different Strategies</h2>
<p>Here are some of the most proven strategies you can utilize to remove extra bits and bytes from your websites.</p>
<ol>
<li><h3 id="heading-perform-code-optimization">Perform Code Optimization:</h3>
</li>
</ol>
<p>Excessive HTML, CSS, and JavaScript can greatly slow down a website. Due to the large code file, the host server will have to transfer more packets to the client browser, ultimately resulting in slow loading.</p>
<p>To resolve this issue, it is always recommended to perform code optimization. The most widely known and used technique for this purpose is minification. It refers to the process of removing all the:</p>
<ul>
<li><p>Unnecessary characters</p>
</li>
<li><p>White spaces</p>
</li>
<li><p>Line breaks</p>
</li>
<li><p>Comments</p>
</li>
<li><p>Unused elements.</p>
</li>
</ul>
<p>But you’ll want to make sure that the code works as before, even after minification.</p>
<p>Optimizing code boosts application performance by reducing execution time and resource consumption. Refactor inefficient loops, minimize database queries, and leverage caching to enhance speed. You can use profiling tools to identify bottlenecks and streamline functions for smoother, faster performance.</p>
<p>To demonstrate better, below I have discussed an example:</p>
<p><strong>Unoptimized JavaScript Code:</strong></p>
<pre><code class="lang-javascript">greet(name) {
    <span class="hljs-keyword">if</span> (!name) {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Hello, Guest!"</span>);
    } <span class="hljs-keyword">else</span> {
        <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Hello, "</span> + name + <span class="hljs-string">"!"</span>);
    }
}
greet(<span class="hljs-string">"John"</span>);
</code></pre>
<p><strong>Minified Version:</strong></p>
<pre><code class="lang-javascript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">greet</span>(<span class="hljs-params">n</span>)</span>{<span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Hello, "</span>+(n||<span class="hljs-string">"Guest"</span>)+<span class="hljs-string">"!"</span>)}greet(<span class="hljs-string">"John"</span>);
</code></pre>
<p>As you can see, I created the minified version by removing all the line breaks and whitespaces. Apart from this, I used shortened variables, like “<strong>n</strong>” instead of “<strong>Name</strong>.” Finally, I also replaced the If Else statement with a shorter n || "Guest" expression.</p>
<p>This is how you can easily condense the entire HTML, CSS, and JavaScript code of your website, and enhance the overall loading speed.</p>
<p>Just keep in mind that there are multiple downsides of code minification. For instance, it significantly impacts code readability and can cause challenges in debugging and maintenance. So use this approach judiciously.</p>
<ol start="2">
<li><h3 id="heading-image-amp-media-optimization">Image &amp; Media Optimization:</h3>
</li>
</ol>
<p>Apart from code, unoptimized images, <a target="_blank" href="https://logocreator.io/blog/logo-file-formats/">logo files</a> and other media files are often the main culprits behind the slow <a target="_blank" href="https://www.freecodecamp.org/news/developers-guide-to-website-speed-optimization/">loading speed</a> of a website. This means that you also need to optimize them as well. There are numerous things you can do in this regard.</p>
<p>First of all – you should reduce the image size in terms of storage. It is generally recommended that each <a target="_blank" href="https://www.foregroundweb.com/image-size/">picture should be less than 500 KB in size</a>. But note that this size can vary depending on the use case.</p>
<p>It’s also a good idea to choose next-generation picture formats like WebP instead of typical ones like JPEG or PNG. When it comes to video files, it’s also helpful if you go with the embedded ones from platforms like YouTube.</p>
<p><strong>Now, let us explain all this with a proper example (Before &amp; After).</strong></p>
<p>Let’s say that a website uses a 2MB JPEG image for its blog post. Its optimization process will involve the following steps:</p>
<ul>
<li><p>Resize the image first. The recommended dimensions are 1200x800.</p>
</li>
<li><p>Compress the image size using image compression tools (we’ll discuss one such tool later in this article)</p>
</li>
<li><p>Now, convert the JPEG file into WebP format.</p>
</li>
<li><p>Add alternative text before publishing</p>
</li>
</ul>
<p><strong>After optimization:</strong></p>
<ul>
<li><p>The image file size will now be reduced to KBS somewhere around 120Kb.</p>
</li>
<li><p>Your website will experience better loading speed as well as an improved user experience.</p>
</li>
</ul>
<p>One more tip that you can consider is <a target="_blank" href="https://www.freecodecamp.org/news/how-lazy-loading-works-in-web-development/">lazy loading</a>. This means only loading the images and videos when they are about to be consumed.</p>
<p>By taking care of these few things, you can efficiently optimize images and media files to achieve faster loading speeds.</p>
<ol start="3">
<li><h3 id="heading-manage-plugins-amp-scripts">Manage Plugins &amp; Scripts:</h3>
</li>
</ol>
<p>Your website may contain unused plugins and scripts that can cause bloat. So, to remove the extra bits and bytes, it is essential to perform regular check-ins.</p>
<p>First, make sure you deactivate and delete all the plugins that aren’t needed. Then, start exploring more lightweight alternatives for plugins that you are actively using. If you find any, go for them and uninstall the bulky ones to improve performance and enhance security, especially for processes like identity verification. Ensure you’re using the latest, most optimized version..</p>
<p>For example, Revolution Slider is a heavy plugin. It loads large scripts and images on every page, even when not needed. This ultimately affects the overall website speed. Some of its lightweight alternatives that you might consider for this include <a target="_blank" href="https://smartslider3.com/">Smart Slider 3</a>, or any other CSS-based slider.</p>
<p>Next comes script management. Here you should first limit any third-party scripts, such as excessive code tracking, social media widgets, and embedded content. Apart from this, don’t forget to totally disable scripts on the pages where they aren’t required.</p>
<p>One useful example here is Google Analytics which loads tracking scripts on every page, increasing the request time. To fix this issue, you can use <a target="_blank" href="https://tagmanager.google.com/">Google Tag Manager</a> to load the scripts only when they are needed.</p>
<p>Additionally, you can use <a target="_blank" href="https://www.blaze.tech/post/no-code-automation-how-to-streamline-your-business-now">no-code workflow automation tools</a> like Zapier, Make, or Uncanny Automator which help streamline processes by reducing reliance on heavy plugins and scripts.</p>
<ol start="4">
<li><h3 id="heading-server-amp-hosting-upgrades">Server &amp; Hosting Upgrades:</h3>
</li>
</ol>
<p>This is the final strategy that you can consider. Your hosting provider plays a key role in deciding the loading speed of the website. So, it’s a good idea to upgrade your hosting plan and get it from a reputable and credible service.</p>
<p>Also, do not forget to enable server-side compression. Doing so will automatically reduce the file sizes before transmission. Optimizing database performance is equally crucial, as <a target="_blank" href="https://www.liquibase.com/resources/guides/database-observability">database observability enables database pipeline analytics</a>, helping to identify inefficiencies, reduce query execution time, and enhance overall site responsiveness.</p>
<p>Also, take steps to optimize the database queries. You can do this by removing unnecessary data while also caching data mechanisms. There are also specialized plugins available for this like <a target="_blank" href="https://wordpress.org/plugins/wp-optimize/">WP-Optimize</a>. It effectively cleans up all the unnecessary data saving valuable time and effort.</p>
<p>You should also start caching queries. Store all the frequent ones in memory. This will significantly reduce database load.</p>
<pre><code class="lang-sql"><span class="hljs-keyword">SELECT</span> * <span class="hljs-keyword">FROM</span> products <span class="hljs-keyword">WHERE</span> <span class="hljs-keyword">category</span> = <span class="hljs-string">'Laptops'</span> <span class="hljs-keyword">CACHE</span>;
</code></pre>
<p>This prevents the server from re-executing the same query repeatedly.</p>
<p>So, these are some of the proven strategies you can apply to eliminate additional bits &amp; bytes from the website to achieve faster loading.</p>
<h2 id="heading-tools-that-you-can-use-to-streamline-the-process">Tools That You Can Use to Streamline the Process</h2>
<p>To simplify the process of optimizing website loading speed, you can consider utilizing the following tools.</p>
<ol>
<li><h3 id="heading-minifierhttpswwwminifierorg"><a target="_blank" href="https://www.minifier.org/">Minifier</a></h3>
</li>
</ol>
<p>First of all, we have Minifier, a dedicated tool that is specifically designed to automate the code minification process with a single click. It is available for free and works for HTML, CSS, and JavaScript codes.</p>
<p>Besides this, the tool features a user-intuitive interface so that you can quickly navigate through it. The minifier is trained according to both development and minification to ensure maximum speed and accuracy in the output.</p>
<p>All you need to do is either paste or upload the code file into the tool, hit the “<strong>Minify</strong>” button, and get a condensed version. You can check out the below screenshot to get a better idea how it works.</p>
<p><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXcu5gaAosAaUCZQ7oIp3J_m_CIEyAshp2Ob6rmguvQOQvxuuz6rXJ1QdO_FSD_McnO1S-fkqv38cY7B0e4s5xBtjNa78mVns2VZRe3iUemWxR-dKgct9-OJkb6YIO2fTkhB_W3If4DYj6hb2vnzknY?key=W-8S2j9mlTlf7KW39H_m9bHu" alt="Screenshot showing Minify result" width="600" height="400" loading="lazy"></p>
<p>Minify also offers a wide variety of other useful tools you can use if needed. Some notable options include JSON minifier and XML formatter, among others.</p>
<p>So now there is no need to spend time and effort on manually minifying your code for better loading speed. You can just use this tool and get the job done with a single click.</p>
<ol start="2">
<li><h3 id="heading-tinypnghttpstinypngcom"><a target="_blank" href="https://tinypng.com/">TinyPNG</a></h3>
</li>
</ol>
<p><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXckb9b-_Pfw-T4icivrTC6g_pnhjpu3BSK0s-7ussuhsRRY22qGNe8DAyUINv8GgGQ5DmY579muEPcGkCjRsbSZofP9XZ1y3xqPBYriFDyh_2vl6yWM4fNYBKaA7k5Jx05pRjjw3ShVU3tT3JjeHwM?key=W-8S2j9mlTlf7KW39H_m9bHu" alt="AD_4nXckb9b-_Pfw-T4icivrTC6g_pnhjpu3BSK0s-7ussuhsRRY22qGNe8DAyUINv8GgGQ5DmY579muEPcGkCjRsbSZofP9XZ1y3xqPBYriFDyh_2vl6yWM4fNYBKaA7k5Jx05pRjjw3ShVU3tT3JjeHwM?key=W-8S2j9mlTlf7KW39H_m9bHu" width="600" height="400" loading="lazy"></p>
<p>Many of you may have heard of or even used this tool. It is an image compression tool that will help you effectively reduce your image sizes for optimization. The good thing is that TinyPNG perfectly preserves the original quality of the picture (in terms of resolution) even after the compression.</p>
<p>All you need to do is upload the required photo from your local storage, and the tool will automatically provide a compressed version. Don’t worry about the file format, as TinyPNG supports JPG, PNG, JPEG, and many more.</p>
<p>The tool even provides the percentage of how much the uploaded image was compressed, like -51%, and so on. It also mentions the size of the compressed photo in terms of KBs. So, in case you are not satisfied with the file size, you can further compress it.</p>
<ol start="3">
<li><h3 id="heading-png-to-webp-converterhttpscloudconvertcompng-to-webp"><a target="_blank" href="https://cloudconvert.com/png-to-webp">PNG to WebP Converter</a>:</h3>
</li>
</ol>
<p><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXcSaBUNNRRqk5A4EetHdg1CK1P6F-Ro213s3DnifuZZFF24BNJZHsP_qXjVe1rn72iPH2jZd707JRsOSIUe7PzEAH7jE0ccacHXaEbqJ0YDILtM4K4gF5IYao0wOpJ13jw-xNOzrKaJiRP926kjqQ?key=W-8S2j9mlTlf7KW39H_m9bHu" alt="AD_4nXcSaBUNNRRqk5A4EetHdg1CK1P6F-Ro213s3DnifuZZFF24BNJZHsP_qXjVe1rn72iPH2jZd707JRsOSIUe7PzEAH7jE0ccacHXaEbqJ0YDILtM4K4gF5IYao0wOpJ13jw-xNOzrKaJiRP926kjqQ?key=W-8S2j9mlTlf7KW39H_m9bHu" width="600" height="400" loading="lazy"></p>
<p>As I mentioned earlier, I recommend using next-gen image formats like WebP instead of older formats when possible. Usually, the widely used format is PNG, but to seamlessly convert into WebP, you can use this PNG to WebP converter.</p>
<p>It’s available for free and does not ask for registration/signup. Simply visit the page and start performing conversions. The conversion is performed without causing any damage to the image’s quality and formatting.</p>
<p>The tool also offers many extra features. For instance, you can adjust both the image’s width and height. You can also set image quality (WebP compression level) if required. And it doesn’t stop here – you can even select the right fit for the photo from the following options:</p>
<ul>
<li><p>Max</p>
</li>
<li><p>Crop</p>
</li>
<li><p>Scale</p>
</li>
</ul>
<ol start="4">
<li><h3 id="heading-google-pagespeed-insighthttpspagespeedwebdev"><a target="_blank" href="https://pagespeed.web.dev/">Google PageSpeed Insight</a></h3>
</li>
</ol>
<p>How can you enhance the loading speed of the website when you don’t even know which elements are causing issues? For this purpose, Google PageSpeed Insight is the best solution. It is developed and managed by Google.</p>
<p>The tool effectively crawls the given page link and highlights all the issues that are causing slow loading. It even provides four different scores (0-100) for evaluation. These include:</p>
<ul>
<li><p>Performance</p>
</li>
<li><p>Accessibility</p>
</li>
<li><p>Best Practices</p>
</li>
<li><p>SEO</p>
</li>
</ul>
<p><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXet-k73bv-y0HQXbGHfrcntmms8k_nQvcrrADNI3w9cBrFKGv9CAkMSEdOCWHFVyuRQxVXaUseYQxIa_2GA9Hl7TzDGSSO_vZqZliiX32ZNdkvoQZYhCf4i3PyKtGHOzk8pwqZ6O-gZRCwPC3gzBt0?key=W-8S2j9mlTlf7KW39H_m9bHu" alt="AD_4nXet-k73bv-y0HQXbGHfrcntmms8k_nQvcrrADNI3w9cBrFKGv9CAkMSEdOCWHFVyuRQxVXaUseYQxIa_2GA9Hl7TzDGSSO_vZqZliiX32ZNdkvoQZYhCf4i3PyKtGHOzk8pwqZ6O-gZRCwPC3gzBt0?key=W-8S2j9mlTlf7KW39H_m9bHu" width="600" height="400" loading="lazy"></p>
<p>The good thing is that Google PageSpeed Insights evaluates the page for both mobile and desktop users. The results are also provided separately. The areas of improvement are highlighted in red, along with the necessary instructions you can take. The good parts are marked with green.</p>
<p>By utilizing this tool, you can easily evaluate your website and then make efforts to improve the loading speed.</p>
<ol start="5">
<li><h3 id="heading-cloudflarehttpswwwcloudflarecom"><a target="_blank" href="https://www.cloudflare.com/">Cloudflare</a></h3>
</li>
</ol>
<p><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXeKEUhCvUoLArPXA_KRMaH4ws28-YXc6OSzP9idKqis14maZynQIrYUoHaJWF1LJ20q7UcFjAhUGc7WRKpk1S37tkaanh5VRqguD2u7ICzp5eFY5e0mMNjZJU_yl-YCm2O1hdaq2gsnwpWJDbPMGGI?key=W-8S2j9mlTlf7KW39H_m9bHu" alt="AD_4nXeKEUhCvUoLArPXA_KRMaH4ws28-YXc6OSzP9idKqis14maZynQIrYUoHaJWF1LJ20q7UcFjAhUGc7WRKpk1S37tkaanh5VRqguD2u7ICzp5eFY5e0mMNjZJU_yl-YCm2O1hdaq2gsnwpWJDbPMGGI?key=W-8S2j9mlTlf7KW39H_m9bHu" width="600" height="400" loading="lazy"></p>
<p>Last but not least, Cloudflare is a good tool that helps enhance the loading speed of a website by using its global content delivery network (CDN). With this feature, it caches static content across different servers worldwide. This ultimately reduces the overall latency and improves loading speed for users in different locations.</p>
<p>Besides this, Cloudflare also offers a bunch of other features. For example, it automatically minifies HTML, CSS, and JavaScript files. It can even compress and convert images into next-gen formats, especially WebP.</p>
<p>It offers a robust DNS resolution that reduces lookup times and helps the page load faster. This feature also protects the site from DDoS attacks.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>If you want to experience higher ranking and increased user engagement, then you need to optimize your website’s loading speed. The extra bits and bytes like code files, media, and so on can cause real hurdles – but don’t worry.</p>
<p>By using these strategies and tools, you’ll be able to speed up page loading in no time. I hope you found this article interesting and valuable.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Set Up a Custom Email with Cloudflare and Mailgun ]]>
                </title>
                <description>
                    <![CDATA[ As a software engineer, you may consider having a professional email account along with your own website, like "info@example.com". But this may cost a certain amount that you'll not be willing to pay. But do you know you can do it for free? There is ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-set-up-custom-email/</link>
                <guid isPermaLink="false">66ba2af24d935175898a7069</guid>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ email ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ San B ]]>
                </dc:creator>
                <pubDate>Mon, 15 Apr 2024 13:49:03 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2024/04/boolfalse-gmail-manage-custom-email.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As a software engineer, you may consider having a professional email account along with your own website, like "<em>info@example.com</em>". But this may cost a certain amount that you'll not be willing to pay.</p>
<p>But do you know you can do it for free? There is actually a way to do it, and besides the fact that having the professional email account is free, it will help you be more efficient, reliable and secure in your daily work.</p>
<p>In this article, you'll learn how to create and set up your own email address using Cloudflare and Mailgun to manage emails in Gmail. This means that you can send and receive emails directly in your Gmail inbox.</p>
<p>I've done this already for personal use and have taken screenshots of the entire process that you'll see in this article. So I'll share all the necessary steps you need to follow to set up your own email.</p>
<p>Let's figure out what you need to have before you start, what you are going to do, and how it will work.</p>
<h2 id="heading-what-you-need-to-have-before-you-start">What you need to have before you start</h2>
<p>I assume that you already have a domain, let's call it "<em>yourdomain.com</em>". Specifically, you need to have accessibility to connect your domain with Cloudflare and setup DNS records there. A classic example of that is having a domain on some domain registrar (like GoDaddy, Namecheap), and adding your domain to Cloudflare by setting DNS records provided by Cloudflare on your domain registrar account.</p>
<p>Adding a domain to Cloudflare involves updating your domain's DNS nameservers to point to Cloudflare's nameservers. Once the domain is added, Cloudflare acts as an intermediary for web traffic, providing security features like DDoS protection, firewall, and SSL encryption, as well as performance enhancements through caching and content optimization.</p>
<p>If you haven't done that yet, here's the official <a target="_blank" href="https://www.youtube.com/watch?v=7hY3gp_-9EU">video on YouTube</a> on how to connect your domain to Cloudflare.</p>
<p>Additionally, Cloudflare manages DNS records for your domain, allowing you to control how traffic is routed and ensuring reliable delivery of services like email.<br>So, our work in this article will be focusing exactly on that: how to setup your domain on Cloudflare Email.</p>
<p><a target="_blank" href="https://blog.cloudflare.com/email-routing-leaves-beta/">Cloudflare Email</a> has been one of the services of Cloudflare since 2021, which can be used for free (as of now, at least).</p>
<p>The second assumption is that you have Gmail account, and you have access to its email settings. Simply, if you just have a regular "<em>youremail@gmail.com</em>" email, which isn't under the control of any administrator, then you have nothing to worry about. We'll explore and work on email settings later on.</p>
<h2 id="heading-what-you-are-going-to-do">What you are going to do</h2>
<p>In simple words, you're going to create a custom email like "<em>something@yourdomain.com</em>", which you can use to send and receive emails using Gmail's platform. So you will be receiving and reading emails sent to "<em>something@yourdomain.com</em>" in Gmail, as well as sending emails from that custom email using Gmail.</p>
<p>You'll use Cloudflare Email for the email routing, and Mailgun's SMTP server for sending emails.</p>
<h2 id="heading-how-it-will-work">How it will work</h2>
<p>When composing an email from Gmail with the sender set as "<em>something@yourdomain.com</em>", Gmail utilizes Mailgun's SMTP server through the provided credentials, transmitting the email. Mailgun then processes the message and forwards it to the recipient's email server, likely involving DNS lookups to find the recipient's server.</p>
<p>Emails sent to "<em>something@yourdomain.com</em>" are received by Cloudflare's email servers, configured via MX records in the domain's DNS settings. Cloudflare stores the received emails in the associated account, accessible through Gmail, which periodically connects to Cloudflare's servers (using IMAP or POP3 protocols) to retrieve new messages, enabling seamless access to incoming emails.</p>
<h2 id="heading-email-routing-on-cloudflare">Email Routing on Cloudflare</h2>
<blockquote>
<p>Cloudflare Email Routing is designed to simplify the way you create and manage email addresses, without needing to keep an eye on additional mailboxes. With Email Routing, you can create any number of custom email addresses to use in situations where you do not want to share your primary email address, such as when you subscribe to a new service or newsletter. Emails are then routed to your preferred email inbox, without you ever having to expose your primary email address. (<a target="_blank" href="https://developers.cloudflare.com/email-routing/">Cloudflare Docs</a>)</p>
</blockquote>
<p>Sign in to your Cloudflare account and navigate to the Dashboard.<br>Choose and click on the desired website. For me it's "<em>boolfalse.com</em>", as I want to create a custom email like "<em>email@boolfalse.com</em>".</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/01-dashboard.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: Websites</em></p>
<p>Navigate to <strong>Email Routing</strong> for the selected website.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/02-email-routing.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: Email Routing</em></p>
<p>If you don't have email routing configured, you may see something similar to the screenshot above. Click "Get started". You may be able to create your own address to receive emails and take action.</p>
<p>We'll skip this without creating our own address because we'll do it manually.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/03-skip-custom-address.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: Custom Email</em></p>
<p>By default, email routing is disabled, so you need to enable it. Click the link to navigate to the <strong>Email Routing</strong> page.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/04-enable-email-routing.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: Email Routing</em></p>
<p>Submit it by clicking "Enable Email Routing".</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/05-email-dns-records-enable-email-routing.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: Enable Email Routing</em></p>
<p>You need to have three MX and one TXT records:</p>
<ul>
<li>Type: <em><strong>MX</strong></em>; Name: <em><strong>@</strong></em>; Mail Server: <em><strong>route1.mx.cloudflare.net</strong></em>; TTL: <strong><em>Auto</em></strong>; Priority: <em><strong>69</strong></em></li>
<li>Type: <em><strong>MX</strong></em>; Name: <em><strong>@</strong></em>; Mail Server: <em><strong>route2.mx.cloudflare.net</strong></em>; TTL: <strong><em>Auto</em></strong>; Priority: <strong><em>99</em></strong></li>
<li>Type: <em><strong>MX</strong></em>; Name: <strong><em>@</em></strong>; Mail Server: <em><strong>route3.mx.cloudflare.net</strong></em>; TTL: <strong><em>Auto</em></strong>; Priority: <strong><em>40</em></strong></li>
<li>Type: <em><strong>TXT</strong></em>; Name: <em><strong>@</strong></em>; TTL: <strong><em>Auto</em></strong>; Content: <strong>_v=spf1 include:<em>spf.mx.cloudflare.net ~all</em></strong></li>
</ul>
<p>You can see them at the bottom of the <strong>Email Routing</strong> page.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/06-required-dns-records.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: DNS records for Email Routing</em></p>
<p>So, as already said, in the left menu, go to "DNS" -&gt; "Records" and add the following records there.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/06-dns-records-added-2.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: DNS records added</em></p>
<p>After creating these records, go to the <strong>Email Routing</strong> page again.</p>
<p>Here, you only need to have the records you just created. So if you have any other records, just delete them.</p>
<p>For example, I already had an unnecessary entry there that I should delete.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/07-unnecessary-dns-records.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: existing records for Email Routing</em></p>
<p>Submit to delete existing unnecessary records.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/08-delete-existing-dns-records.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: deleting unnecessary records</em></p>
<p>After removing unnecessary DNS records, you will see only the ones you need there.</p>
<p>You will now be able to enable email routing by clicking the "Add records and enable" button.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/09-enabling-email-routing.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: Enable Email Routing</em></p>
<p>After enabling it you should see something like this:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/10-email-routing-enabled.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: Email DNS records configured</em></p>
<h2 id="heading-how-to-create-a-custom-email-on-cloudflare">How to Create a Custom Email on Cloudflare</h2>
<p>Now go to the <strong>Routes</strong> tab and create an email by clicking the "Create address" button.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/11-email-routing-routes-tab.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: Email Routing (enabled)</em></p>
<p>In this example, we'll create "<em>email@boolfalse.com</em>" email address, by adding "<em>email</em>" as a custom address, and a destination email address, where I'll be able to receive emails.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/12-creating-email-address.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: Email Routing</em></p>
<p>You should see a notification about that.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/13-email-address-created.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: creating a custom email</em></p>
<p>You should also get an email for confirming this action.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/14-getting-confirmation-email.png" alt="Image" width="600" height="400" loading="lazy">
<em>Verifying the destination email</em></p>
<p>Go on and verify the email address.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/15-verify-email-address.png" alt="Image" width="600" height="400" loading="lazy">
<em>Verify email address</em></p>
<p>Once you've verified the email address, you may get this page:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/16-email-address-verified.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: custom email address is verified</em></p>
<p>You will probably get an email that you've verified your domain with Mailgun:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/36-mailgun-domain-verified-2.png" alt="Image" width="600" height="400" loading="lazy">
<em>Notification about custom email address verification</em></p>
<h2 id="heading-how-to-receive-emails-in-the-custom-email">How to Receive Emails in the Custom Email</h2>
<p>Now, your email address is activated, and you can see that here:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/17-email-address-activated.png" alt="Image" width="600" height="400" loading="lazy">
<em>Cloudflare: custom email address is active</em></p>
<p>At this point you can send emails to the custom email you just set up. In this case, it's "<em>email@boolfalse.com</em>".</p>
<p>Below is a test email sent from a different email.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/18-test-email-sending-1.png" alt="Image" width="600" height="400" loading="lazy">
<em>Testing email receiving</em></p>
<p>You'll receive a test email to the custom email.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/19-test-email-received.png" alt="Image" width="600" height="400" loading="lazy">
<em>Test email has been received</em></p>
<h2 id="heading-mailgun-adding-new-domain">Mailgun: Adding New Domain</h2>
<p>You can now successfully receive emails, but you can't send emails from that custom email yet.</p>
<p>So, it's time to switch to the mail service provider. In our case, it will be <a target="_blank" href="https://www.mailgun.com/">Mailgun</a>.<br>To do this, you just need to register and attach the card to your Mailgun account. After activating your account with the card attached, you can set up a domain for your email.</p>
<p>You don't have to worry about the card, because Mailgun does not charge for limited quantities. I think the amount it gives is quite suitable for a free package.<br>You can find the price packages in detail <a target="_blank" href="https://www.mailgun.com/pricing/">here</a>.</p>
<p>Go to <strong>Sending</strong> -&gt; <strong>Domains</strong> page, and click the "Add New Domain" button.</p>
<p>In our case it will be "<em>mg.boolfalse.com</em>", as Mailgun recommends that to be able to send emails from your root domain, that is: "<em>email@boolfalse.com</em>".</p>
<p>You should see that recommendation on the right in below image:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/24-mailgun-adding-domain.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: create a new domain</em></p>
<p>You can also select the domain region and DCIM key length, but you can leave everything as default.<br>I will leave DCIM key length as 1024 and "US" as a domain region.</p>
<p>After creating the domain, you may be shown some tips on how to verify your domain.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/23-add-new-domain-2.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: adding a new domain</em></p>
<p>Mailgun will give you two TXT records, two MX records and one CNAME record to add to your provider.</p>
<ul>
<li>Type: <em><strong>TXT</strong></em>; Name: _<strong>mailto._domainkey.mg.boolfalse.com</strong>_; TTL: <strong><em>Auto</em></strong>; Content: <strong><em></em></strong></li>
<li>Type: <em><strong>TXT</strong></em>; Name: <em><strong>mg.boolfalse.com</strong></em>; TTL: <strong><em>Auto</em></strong>; Content: <strong><em>v=spf1 include:mailgun.org ~all</em></strong></li>
<li>Type: <em><strong>MX</strong></em>; Name: <em><strong>mg.boolfalse.com</strong></em>; Mail Server: <em><strong>mxa.mailgun.org</strong></em>; TTL: <strong><em>Auto</em></strong>; Priority: <em><strong>10</strong></em></li>
<li>Type: <em><strong>MX</strong></em>; Name <strong><em>mg.boolfalse.com</em></strong>; Mail Server: <em><strong>mxb.mailgun.org</strong></em>; TTL: <strong><em>Auto</em></strong>; Priority: <strong><em>10</em></strong></li>
<li>Type: <strong><em>CNAME</em></strong>; Name: <strong><em>email</em></strong>; Target: <strong><em>mailgun.org</em></strong>; TTL: <strong><em>Auto</em></strong>; Proxy Status: <strong><em>On</em></strong></li>
</ul>
<p>In our case, we will add them to Cloudflare.</p>
<p>Below is the first TXT record:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/27-mailgun-dns-record-1-new.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: first TXT record for a new domain</em></p>
<p>Below is the second TXT record:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/29-mailgun-dns-record-2-new.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: second TXT record for a new domain</em></p>
<p>Below is the first MX record:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/30-mailgun-dns-record-3.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: first MX record for a new domain</em></p>
<p>Below is the second MX record:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/31-mailgun-dns-record-4.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: second MX record for a new domain</em></p>
<p>After you've added two TXT and two MX records, you can check and verify them by clicking the "Verify DNS Records" button.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/32-mailgun-checking-dns-records.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: checking TXT and MX records for a new domain</em></p>
<p>Lastly, add CNAME record.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/33-mailgun-dns-record-5-2.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: adding CNAME record for a new domain</em></p>
<p>You may see a warning icon on the left of the CNAME record. You don't need to worry about that. Here's what <a target="_blank" href="https://developers.cloudflare.com/ssl/edge-certificates/additional-options/total-tls/error-messages">official documentation</a> says about it:</p>
<blockquote>
<p>If you recently <a target="_blank" href="https://developers.cloudflare.com/fundamentals/setup/manage-domains/add-site/">added your domain</a> to Cloudflare - meaning that your zone is in a <a target="_blank" href="https://developers.cloudflare.com/dns/zone-setups/reference/domain-status/">pending state</a> - you can often ignore this warning.<br>Once most domains becomes <strong>Active</strong>, Cloudflare will automatically issue a Universal SSL certificate, which will provide SSL/TLS coverage and remove the warning message.</p>
</blockquote>
<p>After adding a CNAME record, you can check and verify it again by clicking the second "Verify DNS Records" button.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/34-mailgun-checking-dns-records.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: checking CNAME record for a new domain</em></p>
<p>If you have added all 5 records on the Cloudflare successfully, after clicking the verifying button, Mailgun will automatically redirect you to the <strong>Overview</strong> page.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/36-mailgun-verified-1.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: 2 TXT, 2 MX and 1 CNAME records added for a new domain</em></p>
<p>It means you're ready to add a Sending API key on Mailgun.</p>
<h2 id="heading-mailgun-sending-api-key-amp-smpt-user">Mailgun: Sending API key &amp; SMPT User</h2>
<p>Go to <strong>Sending</strong> -&gt; <strong>Domain Settings</strong> page. Choose the <strong>Sending API keys</strong> tab at the top. You probably won't see any API keys there. You just need to create a new Sending API key. </p>
<p>Click "Add sending key" from the top right corner, and in the pop-up, fill the name of the key you're about to create.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/37-mailgun-create-sending-api-key-1.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: creating a Sending API key</em></p>
<p>After pressing "Create sending key", you'll get the secret API key that you need to copy and save somewhere safe. After saving the key, you can just close the pop-up.</p>
<p>You should see the created key listed:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/38-mailgun-sending-api-key-created.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: Sending API key created</em></p>
<p>You also need to create a new SMTP user in the Mailgun dashbaord.<br>Go to <strong>Sending</strong> -&gt; <strong>Domain Settings</strong> page. Choose the <strong>SMTP credentials</strong> tab at the top and click the "Add new SMTP user" button on the top left corner. It will open up a pop-up. </p>
<p>Type user credentials there. In our case I'll create a user with the name "email". It will be like a login for your email on Gmail.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/41-mailgun-create-smtp-user.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: creating SMTP user</em></p>
<p>Once you create an SMTP user in Mailgun, you'll see it listed and a password for that user will be generated automatically. To get this password, copy it by clicking the "Copy" button in the pop-up notification in the lower right corner.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/42-mailgun-smtp-user-created.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: SMTP user created</em></p>
<p>Keep this in a safe place for future use. You will need this login and password to authenticate on Gmail.</p>
<p>You are now ready to set up email configurations with your email provider. In our case, we will do this in Gmail.</p>
<p>Open your Gmail account in your desktop browser and go to Settings by clicking the settings icon in the top right corner and click the "See all settings" button.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/39-gmail-settings-page.png" alt="Image" width="600" height="400" loading="lazy">
<em>Mailgun: new domain is verified</em></p>
<h2 id="heading-gmail-authentication-with-mailgun-smtp-server">Gmail Authentication with Mailgun SMTP Server</h2>
<p>In the Gmail settings page choose the <strong>Accounts and Import</strong> tab and click on the "Add another email address" from the "Send mail as" section:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/40-gmail-add-another-email-2.png" alt="Image" width="600" height="400" loading="lazy">
<em>Gmail: Settings</em></p>
<p>It will open a pop-up for the authentication. Use the login and the password you just got by creating an SMTP user on Mailgun. Make sure to fill out the credentials correctly.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/43-gmail-add-smtp-user.png" alt="Image" width="600" height="400" loading="lazy">
<em>Gmail: authenticate a new user using a created SMTP server on Mailgun</em></p>
<p>Submit the form by clicking the "Add Account" button. It'll probably ask you to save the username/password in your browser. It's up to you.</p>
<p>And the last important thing here: it'll ask you to verify adding an account.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/44-gmail-verify-account.png" alt="Image" width="600" height="400" loading="lazy">
<em>Gmail: authentication confirmation for a new user</em></p>
<p>For the verification, the confirmation email will be sent to your primary email.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/45-gmail-confirmation-code.png" alt="Image" width="600" height="400" loading="lazy">
<em>Gmail: authentication verification email</em></p>
<p>You can either use the confirmation code to verify it using the pop-up window or simply follow the link provided in the confirmation email.</p>
<p>In this case, we'll click on a link which will open the page, where you'll be asked to confirm. Click on "Confirm" and simply close the previously opened pop-up window without worrying.</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/47-gmail-adding-user-confirmed.png" alt="Image" width="600" height="400" loading="lazy">
<em>Gmail: verifying the authentication</em></p>
<p>Now you're ready to send and receive emails from the custom email you just created.</p>
<p>For sending an email from the custom email, you just need to choose that email as a sender email:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/49-gmail-send-emails-from-custom-email.png" alt="Image" width="600" height="400" loading="lazy">
<em>Gmail: sending emails</em></p>
<p><strong>That's it!</strong></p>
<p>An additional thing that may be useful to you is that you can set the custom email address you just created as the default address for sending emails from Gmail.</p>
<p>You can set this on the settings page in the "Send mail as" section:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2024/04/48-gmail-another-email-default.png" alt="Image" width="600" height="400" loading="lazy">
<em>Gmail: Settings (default sender)</em></p>
<p>I hope this guide will be a good resource for setting up your custom email.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>In this article, you learned how to set up your own email to manage emails in Gmail using Cloudflare Email and Mailgun.</p>
<p>In conclusion, it is worth noting that the choice of tools is not mandatory, other tools can be used instead, but the basic idea and logic will be similar.</p>
<p>You can check out my website at: <a target="_blank" href="https://boolfalse.com/"><strong>boolfalse.com</strong></a></p>
<p>Feel free to share this article. 😇</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Auto-Deploy Your React Apps with Cloudflare Pages ]]>
                </title>
                <description>
                    <![CDATA[ In this article, I'm going to show you how to very quickly deploy any React application with the help of Cloudflare pages.  You'll see how to not only build and deploy your app within minutes using just a few tools, but also how to auto-deploy any fu... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-auto-deploy-your-react-apps-with-cloudflare-pages/</link>
                <guid isPermaLink="false">66d0377131fbfb6c3390f201</guid>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Reed ]]>
                </dc:creator>
                <pubDate>Wed, 21 Apr 2021 18:26:44 +0000</pubDate>
                <media:content url="https://www.freecodecamp.org/news/content/images/2021/04/autodeploy-your-react-apps.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article, I'm going to show you how to very quickly deploy any React application with the help of Cloudflare pages. </p>
<p>You'll see how to not only build and deploy your app within minutes using just a few tools, but also how to auto-deploy any future changes you make through your GitHub account. </p>
<h2 id="heading-how-to-get-started">How to Get Started</h2>
<p>To get started, you'll need the following tools: </p>
<ol>
<li>The package manager npm and version control software Git</li>
<li>Your own (free) GitHub account and Cloudflare account</li>
</ol>
<h2 id="heading-create-our-react-project">Create our React Project</h2>
<p>To deploy a React application we first need to have one created. </p>
<p>Let's build a React app on our computer with the help of Create React App. We can do so by giving it the name "cloudflare-react":</p>
<pre><code class="lang-bash">npx create-react-app cloudflare-react
</code></pre>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/04/cloudflare-react-1.gif" alt="Image" width="600" height="400" loading="lazy"></p>
<h2 id="heading-create-our-github-repository">Create our Github Repository</h2>
<p>And once our project been created successfully, let's go ahead and create a GitHub repository for it. </p>
<p>We use GitHub to be able to keep an online, easy-to-manage record of our individual projects. GitHub also allows other users to make improvements to our code through pull requests. </p>
<p>Cloudflare uses GitHub to keep track of all of our code and whenever we make changes. </p>
<p>To track our new React app, we create a new GitHub repository by going to <a target="_blank" href="https://github.com/new">github.com/new</a>. </p>
<p>Next, we can simply add all of our files and commit them with a message that says what we are doing:</p>
<pre><code class="lang-bash">git add .
git commit -m <span class="hljs-string">"Deploy to Cloudflare Pages"</span>
</code></pre>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/04/cloudflare-react-2.gif" alt="Image" width="600" height="400" loading="lazy"></p>
<p>Next, we need to add the appropriate Git remote, used to push our committed code upstream to our new GitHub repo. </p>
<p>GitHub will tell you the command you need to include for your newly created repo. It should look something like this:</p>
<pre><code class="lang-bash">git remote add origin someurl
</code></pre>
<p>And finally, we can simply run <code>git push -u origin master</code>. </p>
<p>After we refresh our GitHub repo page, we should see all of our React project code, pushed to GitHub. </p>
<p>This is the first main requirement of deploying an application to Cloudflare pages – to have a React application living on GitHub. </p>
<h2 id="heading-create-a-cloudflare-account">Create a Cloudflare Account</h2>
<p>Next, we go to Cloudflare to deploy our React project.</p>
<p>If you don't have a free Cloudflare account already, you can go to <a target="_blank" href="https://pages.cloudflare.com/">pages.cloudflare.com</a> and hit "Sign up":</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/04/cloudflare-react-8.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p>One main reason why you and most other developers would be interested in using Cloudflare pages, is that Cloudflare has a worldwide CDN. This allows for faster delivery of our deployed application. </p>
<p>Cloudflare also has resources such as DNS management, which is especially helpful if you want your application to have its own custom domain. </p>
<h2 id="heading-link-github-to-cloudflare-pages">Link GitHub to Cloudflare Pages</h2>
<p>The first time you visit Cloudflare pages you'll be prompted to create a project from your GitHub repository so you'll select the Connect GitHub account button:</p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/04/cloudflare-react-5.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p>Then you'll be asked to install and authorize Cloudflare pages.</p>
<p>This step allows us to choose what Cloudflare gets access to – whether we want to give it access to all of our repositories or only to select repositories: </p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/04/cloudflare-react-6.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p>If you want to deploy multiple projects in the future I'd recommend selecting all repositories. </p>
<p>As a result, Cloudflare will have the ability to access any code and deployments that we've made so it can be deployed to the web.</p>
<h2 id="heading-deploy-our-react-project-to-cloudflare-pages">Deploy our React Project to Cloudflare Pages</h2>
<p>Once we've given Cloudflare authorization to do so, we'll see a screen where we can choose what project from our GitHub repository we want to deploy: </p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/04/cloudflare-react-7.png" alt="Image" width="600" height="400" loading="lazy"></p>
<p>In our case, we'll choose our "cloudflare-react" repo, after which we will hit Begin setup. </p>
<p>From there we can choose what project name we want our React app to have with Cloudflare. This project name is important because it determines the subdomain that it's going to be deployed to. </p>
<p>Since we chose "cloudflare-react", it will be deployed to cloudflare-react.pages.dev: </p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/04/cloudflare-react-3.gif" alt="Image" width="600" height="400" loading="lazy"></p>
<p>We can choose which branch to deploy, as well as the build settings. </p>
<p>Note that all we really have to do is choose what framework preset we're using. Cloudflare has a preset option for our framework – Create React App. </p>
<p>When we choose it, it's going to include the default settings for any Create React App project: to deploy the project by running the build command "npm run build" and the output directory (the folder to which our React code will be built upon running this build command) is named "build". </p>
<p>There are other helpful presets for any React application that's made with a framework like Next.js or Gatsby. You can use Cloudflare pages to deploy almost any type of React application you can think of.</p>
<p>Finally, we can hit the deploy button. The deployment process will take about four to five minutes the first time. Be patient, but be aware that any subsequent deploy is going to take a lot less time. </p>
<p>We do see some helpful logs about our project being built and the progress of our deployment. If there were an error in that process, we would see it in the logs and get some indication as what as to what we needed to fix. </p>
<p>Then to see our deployed project we can hit the Continue to Project button, hit "Visit Site" and we can see our app running on the URL: your-project-name.pages.dev. </p>
<h2 id="heading-make-changes-with-auto-deploys">Make Changes with Auto Deploys</h2>
<p>While it was very easy to instantly deploy our React application to the web after we had it pushed to GitHub, the next step is to make changes to our app and redeploy it. </p>
<p>As you'll see, this auto-deploy (continuous integration) functionality has already been set up.</p>
<p>In the case of my application, I decided to install React Router DOM to add an about page. On the home page, I also added a link to the about page: </p>
<p><img src="https://www.freecodecamp.org/news/content/images/2021/04/cloudflare-react-4.gif" alt="Image" width="600" height="400" loading="lazy"></p>
<p>After I was done performing that change which you can see in the video above, I went through the same process of running <code>git add .</code>, <code>git commit</code> with a message about the changes that I made, and then <code>git push</code>. </p>
<p>After doing so, if we flip back to our Cloudflare pages dashboard, we can see that immediately Cloudflare has picked up this new deploy because it's linked to our GitHub account and can view any deploys or pull requests that were made to our repo. </p>
<p>As a result, it instantly re-deploys our app with the changes that we made. As our deploy is taking place, we can hit "View build" and can see specific information about this deploy, along with any logs. </p>
<p>As you will see, any change made after the initial deploy takes a lot less time (it only takes about a minute in total for the deploy to finish successfully). You will also see that it's given its own unique deploy hash at the beginning of our URL. This allows us to uniquely reference each deploy.</p>
<p>If we remove the hash, we see that the changes that we made are also deployed to our chosen project name: cloudflare-react.pages.dev.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>I hope that this tutorial shows you just how easily it is to get started with the new Cloudflare pages. You can start deploying your React apps to it today to take advantage of their global CDN and all the additional features that Cloudflare has to offer. </p>
<p>Cloudflare pages is still quite new, but it offers a lot of tools already that are worth checking out. I'd highly recommend it as your deployment service for the next React app you want to share with the world.</p>
<h2 id="heading-become-a-professional-react-developer">Become a Professional React Developer</h2>
<p>React is hard. You shouldn't have to figure it out yourself.</p>
<p>I've put everything I know about React into a single course, to help you reach your goals in record time:</p>
<p><a target="_blank" href="https://www.thereactbootcamp.com"><strong>Introducing: The React Bootcamp</strong></a></p>
<p><strong>It’s the one course I wish I had when I started learning React.</strong></p>
<p>Click below to try the React Bootcamp for yourself:</p>
<p><a target="_blank" href="https://www.thereactbootcamp.com"><img src="https://reedbarger.nyc3.digitaloceanspaces.com/reactbootcamp/react-bootcamp-cta-alt.png" alt="Click to join the React Bootcamp" width="600" height="400" loading="lazy"></a>
<em>Click to get started</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ An Illustrated Guide for Setting Up Your Website Using Github & Cloudflare ]]>
                </title>
                <description>
                    <![CDATA[ By Karan Thakkar You should read this if… You want to setup custom redirects or other server configuration for free You want to get your site on HTTPS but don’t know where to start You’re overwhelmed with the amount of choices out there (like Netlif... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/an-illustrated-guide-for-setting-up-your-website-using-github-cloudflare-5a7a11ca9465/</link>
                <guid isPermaLink="false">66c343dfccd54aa295e92c88</guid>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ General Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ startup ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ freeCodeCamp ]]>
                </dc:creator>
                <pubDate>Tue, 25 Apr 2017 18:20:21 +0000</pubDate>
                <media:content url="https://cdn-media-1.freecodecamp.org/images/1*TW_xtI15RW9vMZh4u2szIQ.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>By Karan Thakkar</p>
<p>You should read this if…</p>
<ol>
<li>You want to setup custom redirects or other server configuration <strong>for free</strong></li>
<li>You want to get your site on HTTPS but don’t know where to start</li>
<li>You’re overwhelmed with the amount of choices out there (like <a target="_blank" href="https://www.netlify.com">Netlify</a>, <a target="_blank" href="https://surge.sh">Surge</a>, <a target="_blank" href="https://www.bitballoon.com/">BitBalloon</a>, <a target="_blank" href="https://zeit.co/now">Now</a>)</li>
</ol>
<h3 id="heading-why-github"><strong>Why Github?</strong></h3>
<ol>
<li>Easy to setup and get started with Github Pages</li>
<li>Instant deploys on pushing new code</li>
</ol>
<h3 id="heading-why-cloudflare"><strong>Why Cloudflare?</strong></h3>
<ol>
<li>It’s free</li>
<li>It comes with out-of-the-box support for SSL (HTTPS). (<a target="_blank" href="https://developers.google.com/web/fundamentals/security/encrypt-in-transit/why-https">Here’s why HTTPS matters</a>.)</li>
<li>Super simple DNS management</li>
<li>Ability to set <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control">browser cache expiration</a> for assets</li>
<li>Auto minify your static assets</li>
<li>Custom page rules for setting up redirects, always HTTPS, etc.</li>
<li><a target="_blank" href="https://hpbn.co/http2/">HTTP2</a>/<a target="_blank" href="http://googlecode.blogspot.in/2012/01/making-web-speedier-and-safer-with-spdy.html">SPDY</a> for supported browsers</li>
<li>Allows for setting up <a target="_blank" href="https://www.owasp.org/index.php/HTTP_Strict_Transport_Security_Cheat_Sheet">HSTS</a> (HTTP Strict Transport Security)</li>
</ol>
<h3 id="heading-before-we-get-started-you-will-need-a-few-things">Before we get started you will need a few things:</h3>
<ol>
<li>A <a target="_blank" href="https://github.com/join">Github account</a></li>
<li>A <a target="_blank" href="https://www.cloudflare.com/a/sign-up">Cloudflare account</a></li>
<li>Access to a custom domain. You can buy it from any Domain Name Registrar like: <a target="_blank" href="https://www.namecheap.com/">Namecheap</a>, <a target="_blank" href="http://www.godaddy.com">GoDaddy</a>, <a target="_blank" href="https://www.bigrock.in">BigRock</a>, etc.</li>
</ol>
<p>If all this piqued your interest, then let’s get started!</p>
<h3 id="heading-step-1-create-github-repo-with-your-code"><strong>Step 1</strong>: Create Github repo with your code</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/Pazu9SBLRgkH49CuRwj69THDH-6P0YwQcmGs" alt="Image" width="800" height="322" loading="lazy">
<em>Select the option <strong>Project Site</strong> to get started</em></p>
<ul>
<li>Go to <a target="_blank" href="https://pages.github.com/">https://pages.github.com</a></li>
<li>Select the option <strong>Project Site</strong> to find the instructions on how to create a basic page from scratch or a custom theme</li>
</ul>
<h3 id="heading-step-2-setup-github-pages-for-the-repository">Step 2. Setup Github Pages for the repository</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/NUb2qLmGitHj03aUVC971xLzhpKLAXAXS8K4" alt="Image" width="800" height="108" loading="lazy">
<em>Go to <strong>Settings</strong> for your repository</em></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/iDbE5EYxOtNXFGOtShTGVO251jLHWvD0UFQs" alt="Image" width="800" height="439" loading="lazy">
<em>Choose to serve your website from the <strong>master</strong> branch</em></p>
<p>Go to <strong>Settings</strong> for your repository. In the <strong>Github Pages</strong> section, choose the <strong>master</strong> branch to serve your website from. Once you’ve done that, you can go to <a target="_blank" href="https://<yourgithubusername>.github.io/repository"><strong>https://_.gith_u_b.io/repo</strong></a><strong>s</strong>itory to see your website in action as shown below.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/ZFsmt9wTMIQKRm-mat0mzabszDO2DaaWHBtI" alt="Image" width="800" height="458" loading="lazy"></p>
<h3 id="heading-step-3-add-custom-domain">Step 3. Add custom domain</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/zkaZLSVwWXTLlOaBbbvOY5GYCp71NbUyWKfL" alt="Image" width="800" height="614" loading="lazy">
<em>Add a custom domain for your website</em></p>
<p>Add the custom domain that you have bought and save it. Your website is now ready with your own custom domain ? WOOT! ✨</p>
<p>So, we have everything setup on Github. We’ll start with setting up <a target="_blank" href="https://www.cloudflare.com">Cloudflare</a> to jazz up your website with all the powerful features I mentioned at the beginning of this post.</p>
<h3 id="heading-step-4-setup-your-domain-on-cloudflare">Step 4: Setup your domain on Cloudflare</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/Zu8TUIiDXMS8gVd6QNhQ8CSnuaOH28V2XJ-P" alt="Image" width="800" height="337" loading="lazy"></p>
<p>Login to <a target="_blank" href="https://www.cloudflare.com">Cloudflare</a>. If you are using it for the first time, you should see a screen like the image shown above. If you have used it before, you can click on the <strong>Add Site</strong> option in the navigation bar on the top right to add a new domain. Enter the domain you want to manage and click on <strong>Begin Scan</strong>.</p>
<h3 id="heading-step-5-setup-dns-records-for-your-domain">Step 5: Setup DNS Records for your domain</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/0o3RqClqVNTmOlgOx0XdqFSMuep-HkESF3tQ" alt="Image" width="800" height="431" loading="lazy"></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/BzsyCCy9E8niq5cc-yp73Lnl4Yxha0TBgckh" alt="Image" width="800" height="433" loading="lazy">
<em><strong>Left</strong>: Setup DNS records for apex domain. It is denoted by @. <strong>Right</strong>: Final DNS record list</em></p>
<p>In this step, we inform Cloudflare to point our domain to the <a target="_blank" href="https://help.github.com/articles/setting-up-an-apex-domain/#configuring-a-records-with-your-dns-provider">Github Pages server</a> using two <strong>A Record</strong> DNS entries:   </p>
<ol>
<li>192.30.252.153  </li>
<li>192.30.252.154</li>
</ol>
<p>Once you have set this up, all requests to your custom domain i.e. <em>yourcustomdomain.com</em> will be routed to your website on Github on <a class="post-section-overview" href="#3b17"><strong>Step 3</strong></a>.</p>
<p>There’s one more step involved before we move on to the next stage. Oftentimes, you would want to use a subdomain like <strong>www</strong> for your website, i.e. <em>www.yourcustomdomain.com</em> For this, you will need to add a <strong>CNAME record</strong> DNS entry which will point your subdomain(www) to your apex domain(@).</p>
<p>Once you have set this up, all requests to your custom subdomain i.e. www.<em>yourcustomdomain.com</em> will be routed to your website on Github on <a class="post-section-overview" href="#3b17"><strong>Step 3</strong></a>.</p>
<p><strong>NOTE: Don’t try to go access your custom domain right away. It won’t work. We have only done the Cloudflare to Github setup. We still have to do the DNS Registrar -&gt; Cloudflare setup. This will come up <a class="post-section-overview" href="#fa13">in Ste</a>p</strong> 7.</p>
<p>Click <strong>Continue</strong> to go to the next step.</p>
<h3 id="heading-step-6-choose-the-free-cloudflare-plan">Step 6: Choose the Free Cloudflare plan</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/Hfj01XM5X73NgF4qdFHNZoOrYPFhuwnAeP9t" alt="Image" width="800" height="433" loading="lazy"></p>
<p>The Free plan for Cloudflare provides a lot of sophisticated options as discussed in the <a class="post-section-overview" href="#2847">Why Cloudflare?</a> section at the beginning.</p>
<p>Click <strong>Continue</strong> to go to the next step.</p>
<h3 id="heading-step-7-update-nameservers-on-your-dns-registrar">Step 7: Update Nameservers on your DNS Registrar</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/jj0tx9LYgfGjvgWbK4o3G9jNzFvWB9IT49o7" alt="Image" width="800" height="430" loading="lazy">
<em>Copy these two highlighted nameservers to your DNS registrar’s name server settings</em></p>
<p>Once you’re on this page, keep it open in one tab and open your DNS Registrar’s (the place from where your bought your domain) site in another. If you’re using one of the following registrar’s then the links to understand how to change Nameserver are:</p>
<ol>
<li><a target="_blank" href="http://manage.bigrock.in/kb/servlet/KBServlet/faq455.html">Bigrock</a></li>
<li><a target="_blank" href="https://www.namecheap.com/support/knowledgebase/article.aspx/767/10/how-can-i-change-the-nameservers-for-my-domain">Namecheap</a></li>
<li><a target="_blank" href="https://godaddy.com/help/set-custom-nameservers-for-domains-registered-with-godaddy-12317">GoDaddy</a></li>
</ol>
<p>You need to replace the existing Nameservers in your Domain settings with the one’s on the Cloudflare page that is open in the other tab.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/l8OShr9wOBhUhAUEaK39Qhk0ZY3wWNexIegs" alt="Image" width="800" height="245" loading="lazy"></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/8weSeErWSdaqs3qt030oLmhhwPtBMBHUH82u" alt="Image" width="800" height="323" loading="lazy">
<em>An example of how it would look like after you’ve updated your Nameserver settings in your DNS registrar</em></p>
<p>YASSS! You’ve successfully setup your custom domain to use Cloudflare as a DNS provider. You can go to the <strong>Overview</strong> option on the top and you will find that it is still waiting for your Nameserver change to be processed.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/gZxnctD-E1ynvH15OWmr20pS0VwfsuUK8XUS" alt="Image" width="800" height="412" loading="lazy"></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/CPBfFUpCocFyYLsZS0QZEKnIcFBPhHHscNJs" alt="Image" width="800" height="315" loading="lazy">
<em><strong>Left</strong>: Nameserver change is still being processed. <strong>Right</strong>: Nameserver change is processed!!</em></p>
<p>Once the <strong>Overview</strong> tab says <strong>Status: Active</strong>, you can now try to visit your site using your custom domain, <strong>AND IT SHOULD JUST WORK</strong>! ??</p>
<h3 id="heading-step-8-configure-minification">Step 8: Configure Minification</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/-UlrTmWe2KFzCmp1AEaDXiz8ty7uVnnBbQlc" alt="Image" width="800" height="338" loading="lazy"></p>
<p>In the <strong>Speed</strong> setttings, in the <strong>Auto Minify</strong> section, choose the option to auto-minify everything: Javascript, CSS, HTML. This will be done by Cloudflare on-the-fly once and then cached. Whenever any of your assets change, Cloudflare will do this again for you.</p>
<p>The advantage of minification is that the size of the file delivered to your browser is a lot less since it strips off unwanted spaces and comments.</p>
<h3 id="heading-step-9-configure-browser-cache-expiration">Step 9: Configure Browser Cache Expiration</h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/mlTJMCzd6FA104vxzf16EF-CNcFjhQnUxxzq" alt="Image" width="800" height="141" loading="lazy">
<em>Cache Expiration set to 1 month</em></p>
<p>If you scroll down on the same page as <strong>Auto Minify</strong>, you will find the <strong>Browser Cache Expiration</strong> section. It should be set to 30 days/1 month, ideally, for <a target="_blank" href="https://www.webpagetest.org">WebpageTest</a> to not give you a warning. What this time indicates is that, once your site is loaded in any browser, then the browser will not request any assets for a second time until the Browser Cache time period expires for those assets.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/kz4YtuVfiBzB6VfYkpmkyTPpMZfVa54jx90q" alt="Image" width="800" height="131" loading="lazy">
_Example: The <strong>iphone.png</strong> image loads from your server for the first time (22.3KB in 349ms) All subsequent requests to fetch that resource are served from disk cache which means it is [instantaneously](http://www.softwaretestingclub.com/forum/topics/what-is-the-difference-between-disk-cache-memory-cache-browser?commentId=751045%3AComment%3A304464" rel="noopener" target="_blank" title=""&gt;almost &lt;a href="https://www.reddit.com/r/explainlikeimfive/comments/3660ig/eli5what_is_the_difference_between_disk_caching/crb1c3i/" rel="noopener" target="<em>blank" title=") available (in 5ms)</em></p>
<p>Before we move on to the next step, please check the <strong>Crypto</strong> settings on Cloudflare. It should say <strong>Active Certificate</strong> in the <strong>SSL</strong> section. (<em>Note: Try reloading the page. Sometimes it doesn’t update</em>). In the next two steps, we are going to make your site serve via HTTPS <em>always.</em> For that to work without any problems, it is important that you have an active certificate on Cloudflare.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/J8VNQi2SB589JR0LyZQfuK8vOZ6T5TCJy9Oy" alt="Image" width="800" height="393" loading="lazy"></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/itx2jsX4P4J2Ji-J-y5K98REbZfYnD4uaqkT" alt="Image" width="800" height="392" loading="lazy">
<em>The SSL section shows <strong>Authorizing Certificate</strong> after your Nameserver changes have been processed. Once an SSL certificate for you has been issued, this message will change to <strong>Active Certificate</strong>.</em></p>
<h3 id="heading-step-10-configure-page-rules">Step 10: Configure Page Rules</h3>
<p>In this step, we are going to do two things:</p>
<ol>
<li>Redirect all requests for <strong>www.yourcustomdomain.com</strong> to <strong>yourcustomdomain.com</strong></li>
<li>Redirect all requests for <strong>http://yourcustomdomain.com</strong> to <strong>https://yourcustomdomain.com</strong></li>
</ol>
<p>Go to the <strong>Page Rules</strong> setting and click on <strong>Create Page Rule.</strong></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/WTI5cCO1bX3uOwQzqx35LEADKZM0R87uGV1v" alt="Image" width="800" height="310" loading="lazy"></p>
<p>For handling the <a target="_blank" href="http://www.yourcustomdomain.com">www.yourcustomdomain.com</a> to <strong>yourcustomdomain.com</strong> redirect, replace <strong>tweetify.io</strong> with <strong>yourcustomdomain.com</strong> name. Click <strong>Save and Deploy</strong>.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/7DSCSliTrRLyMYPhwzyAt6acJHmcDrL5ozAp" alt="Image" width="800" height="437" loading="lazy"></p>
<p>For handling the <a target="_blank" href="http://yourcustomdomain.com">http://yourcustomdomain.com</a> to <a target="_blank" href="https://yourcustomdomain.com"><strong>https://yourcustomdomain.com</strong></a> redirect, replace <strong>tweetify.io</strong> with <strong>yourcustomdomain.com</strong> name. Click <strong>Save and Deploy</strong>.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/Hlh3AtXIwFdLiEFxhQx8MRxCs9e5Vnvls7iN" alt="Image" width="800" height="435" loading="lazy"></p>
<h3 id="heading-step-11-configure-hstshttpswwwowasporgindexphphttpstricttransportsecuritycheatsheet">Step 11: Configure <a target="_blank" href="https://www.owasp.org/index.php/HTTP_Strict_Transport_Security_Cheat_Sheet">HSTS</a></h3>
<p><img src="https://cdn-media-1.freecodecamp.org/images/DZA9uBhWCKwH1jgdXrE0y7dZU2IgPkZExk7P" alt="Image" width="800" height="167" loading="lazy"></p>
<p>Go to the <strong>Crypto</strong> settings and scroll down to the <strong>HTTP Strict Transport Security (HSTS)</strong> section. Click on <strong>Enable HSTS</strong>. This will ask you to acknowledge that you know what you are doing. Before you select <strong>I understand</strong>, let me tell you why we need to enable this setting:</p>
<blockquote>
<p>If a user has opened your website in the past, from then onwards whenever the user tries to access your website, they will always be taken to the HTTPS version of your site. This makes your site load a little faster on subsequent visits because the HTTP to HTTPS redirect happens on the client and not via the Cloudflare Page Rule that we added in <a class="post-section-overview" href="#8841">Step 10</a>.</p>
</blockquote>
<p>Once you go to the next step, you should enable all the settings as shown below. You can read more details about these options <a target="_blank" href="https://tools.ietf.org/html/rfc6797#section-6.1.1">here</a> and <a target="_blank" href="https://www.owasp.org/index.php/Security_Headers">here</a></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/NsniSTdbRi9BbuX44xAekAg93ez8Ehv58lmQ" alt="Image" width="800" height="435" loading="lazy">
<em>Screenshot of HSTS settings in Cloudflare</em></p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/LalAmuWF1UBaysA7p5p9xg4Vr1qIYXvr0Bwq" alt="Image" width="606" height="49" loading="lazy">
_Headers that are added by Cloudflare to requests for your domain after you setup HSTS [as shown above](#a96c" rel="noopener" target="<em>blank" title=")</em></p>
<p>That’s it. You’re all set to show off your website to the world! ?? If you found this useful, please ❤️ it and share it.</p>
<p><img src="https://cdn-media-1.freecodecamp.org/images/b-0jLfEMlmveor1qxAnKWoKKZWuXQugsTv6J" alt="Image" width="154" height="154" loading="lazy"></p>
<p><a target="_blank" href="https://twitter.com/geekykaran">Karan Thakkar</a> is the Frontend Lead at <a target="_blank" href="https://www.freecodecamp.org/news/an-illustrated-guide-for-setting-up-your-website-using-github-cloudflare-5a7a11ca9465/undefined">Crowdfire</a> - <em>Your super-smart marketing sidekick</em>. His <a target="_blank" href="https://bit.ly/hackingtwitter">article</a> has been previously <a target="_blank" href="https://bit.ly/geekyonhuffpo">featured</a> on <a target="_blank" href="https://www.freecodecamp.org/news/an-illustrated-guide-for-setting-up-your-website-using-github-cloudflare-5a7a11ca9465/undefined">The Huffington Post</a>. He likes trying out new technologies in his spare time and has built <a target="_blank" href="https://karanjthakkar.com/projects/tweetify">Tweetify</a> (using React Native) and <a target="_blank" href="https://showmyprs.com">Show My PR’s</a> (using Golang).</p>
<p>Other articles written by him:</p>
<p><a target="_blank" href="https://blog.markgrowth.com/how-i-grew-from-300-to-5k-followers-in-just-3-weeks-2436528da845"><strong>How I grew from 300 to 5k followers in just 3 weeks</strong></a><br><a target="_blank" href="https://blog.markgrowth.com/how-i-grew-from-300-to-5k-followers-in-just-3-weeks-2436528da845">_#GrowthHacking my Twitter account for @Crowdfire Twitter Premier League_blog.markgrowth.com</a><a target="_blank" href="https://medium.freecodecamp.com/going-https-on-amazon-ec2-ubuntu-14-04-with-lets-encrypt-certbot-on-nginx-696770649e76"><strong>Using the Let’s Encrypt Certbot to get HTTPS on your Amazon EC2 NGINX box</strong></a><br><a target="_blank" href="https://medium.freecodecamp.com/going-https-on-amazon-ec2-ubuntu-14-04-with-lets-encrypt-certbot-on-nginx-696770649e76">_Let’s Encrypt is a new Certificate Authority which provides free SSL certificates (up to a certain limit per week). It…_medium.freecodecamp.com</a></p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
