Dynamic Star Rating System using Cloudflare Workers and Firebase

Learn how to build a serverless dynamic star rating system with schema markup for websites using Cloudflare Workers and Firebase Realtime Database.

Build a Dynamic Star Rating System with schema markup using Cloudflare Workers and Firebase

Adding a dynamic star rating system to your static blog (like Blogger or standard HTML sites) can significantly boost user engagement. While WordPress users have plenty of plugins for this, static site owners often struggle to find a fast, serverless, and lightweight solution.

Dynamic star rating system with schema markup in website

In this tutorial, we will build a completely custom, dynamic 5-star rating widget using Cloudflare Workers, Firebase Realtime Database, and pure Vanilla JavaScript. This setup ensures zero impact on your website's loading speed and handles CORS policies securely.

Why Use This Serverless Approach?

  • Lightning Fast: Cloudflare Workers act as a blazing-fast proxy.
  • No Server Maintenance: Completely serverless architecture.
  • Spam Protection: Uses localStorage to prevent multiple votes from the same user.
  • Lazy Loading: The JavaScript only triggers 3.5 seconds after the page loads, keeping your core web vitals pristine.

Step 1: The Backend (Cloudflare Worker)

We need a secure way to fetch the current rating count and total score from Firebase without exposing our database directly to read requests or dealing with CORS errors. We will use a Cloudflare Worker for this.

Create a new Cloudflare Worker and paste the following code exactly as it is. This code intercepts requests to /rating, handles cross-origin policies (allowing specific domains like search engines and your site), and fetches the current score from Firebase.


export default {
  async fetch(request) {
    const url = new URL(request.url);
    const origin = request.headers.get('Origin') || request.headers.get('Referer') || "";
    
    // Replace 'yourwebsite.com' with your actual domain
    const isAllowedOrigin = origin.includes('yourwebsite.com') || origin.includes('google') || origin.includes('gstatic') || origin.includes('bing') || origin.includes('msn');

    const headers = {
      'Access-Control-Allow-Origin': isAllowedOrigin ? origin : 'https://www.yourwebsite.com',
      'Access-Control-Allow-Headers': '*',
      'Cache-Control': 'public, max-age=3600'
    };

    // Handle CORS preflight requests
    if (request.method === 'OPTIONS') {
      return new Response(null, { headers });
    }

    // Prevent unauthorized access
    if (!isAllowedOrigin && url.pathname.startsWith('/rating')) {
      return new Response('{"error":"Unauthorized"}', { status: 403, headers });
    }

    // Fetch data from Firebase
    if (url.pathname.startsWith('/rating')) {
      try {
        // Replace YOUR_FIREBASE_PROJECT_ID with your actual project ID
        const response = await fetch(`https://YOUR_FIREBASE_PROJECT_ID-default-rtdb.firebaseio.com/p/${url.searchParams.get('id')}.json`);
        const data = await response.json() || { t: 0, c: 0 };
        
        return new Response(JSON.stringify(data), {
          headers: { 'Content-Type': 'application/json', ...headers }
        });
      } catch (error) {
        return new Response('{"t":0,"c":0}', { headers });
      }
    }
    
    return new Response('OK', { headers });
  }
};

Note: Make sure to map this worker to your desired route (e.g., yourdomain.com/rating*) in your Cloudflare dashboard.

Step 2: The Frontend (HTML, CSS and JavaScript)

Now, let's add the visual widget to our website. You can paste this code directly into your Blogger theme or HTML template where you want the star ratings to appear. This code includes:

  • CSS: For styling the stars and supporting dark mode (.drK).
  • HTML: The placeholder for the stars.
  • JavaScript: It waits for 3.5 seconds, fetches the current rating from your Cloudflare Worker, dynamically imports the Firebase SDK only when a user clicks a star, and updates the database using a Firebase transaction.


<style>
  ._rt_w {
    display: inline-flex !important;
    align-items: center !important;
    flex-direction: row !important;
    flex-wrap: nowrap !important;
    margin: 8px 0;
    padding: 6px 15px;
    border: 1px solid #e0e0e0;
    border-radius: 2px;
    background: #fff;
    transition: all .3s ease;
  }
  .drK ._rt_w {
    background: #252526;
    border-color: #444;
  }
  #_rt_grp {
    display: flex !important;
    line-height: 1;
  }
  ._rt_s {
    font-size: 20px;
    color: #bbb;
    cursor: pointer;
    line-height: 1;
  }
  .drK ._rt_s {
    color: #666;
  }
  ._rt_s._rt_a,
  .drK ._rt_s._rt_a {
    color: #ffc107;
  }
  #_rt_msg {
    font-size: 12px;
    color: #222 !important;
    font-weight: 400 !important;
    margin-left: 10px;
    white-space: nowrap;
    line-height: 1;
    opacity: 1 !important;
  }
  .drK #_rt_msg {
    color: #f0f0f0 !important;
  }
</style>

<div class='_rt_w'>
  <div id='_rt_grp'>
    <span class='_rt_s' d='1'>&#9733;</span>
    <span class='_rt_s' d='2'>&#9733;</span>
    <span class='_rt_s' d='3'>&#9733;</span>
    <span class='_rt_s' d='4'>&#9733;</span>
    <span class='_rt_s' d='5'>&#9733;</span>
  </div>
  <div id='_rt_msg'>Loading...</div>
</div>

<script>
//<![CDATA[
window.addEventListener("load", () => {
  setTimeout(() => {
    const pathId = window.location.pathname.replace(/[./]/g, "_");
    const stars = document.querySelectorAll("._rt_s");
    const msgBox = document.getElementById("_rt_msg");
    const hasVoted = localStorage.getItem("v_" + pathId);

    const updateStarVisuals = (rating) => {
      stars.forEach(star => {
        star.classList.toggle("_rt_a", star.getAttribute("d") <= Math.round(rating));
      });
    };

    // 1. Fetch Existing Rating from Cloudflare Worker
    fetch("/rating?id=" + pathId + (hasVoted ? "&fresh=" + hasVoted : ""))
      .then(res => res.json())
      .then(data => {
        if (data.c) {
          const avgRating = (data.t / data.c).toFixed(1);
          updateStarVisuals(avgRating);
          msgBox.innerText = `Rating: ${avgRating} (${data.c})` + (hasVoted ? " (Voted)" : "");

          // SEO Warning: This injects CreativeWorkSeries schema. 
          // Best practice is to remove this schema generation block for standard blog posts.
          const schemaScript = document.createElement("script");
          schemaScript.type = "application/ld+json";
          schemaScript.text = JSON.stringify({
            "@context": "https://schema.org",
            "@type": "CreativeWorkSeries",
            "name": document.title.replace(/"/g, ""),
            "url": window.location.href,
            "aggregateRating": {
              "@type": "AggregateRating",
              "ratingValue": avgRating,
              "bestRating": "5",
              "ratingCount": data.c
            }
          });
          document.head.appendChild(schemaScript);

        } else {
          msgBox.innerText = "Rate this";
        }
      })
      .catch(() => {
        msgBox.innerText = "Rate this";
      });

    // 2. Handle User Voting
    if (!hasVoted) {
      stars.forEach(star => {
        star.addEventListener("click", async () => {
          const voteValue = parseInt(star.getAttribute("d"));
          msgBox.innerText = "Saving...";

          try {
            // Lazy load Firebase SDKs only on click
            const [fbApp, fbDb] = await Promise.all([
              import("https://www.gstatic.com/firebasejs/12.7.0/firebase-app.js"),
              import("https://www.gstatic.com/firebasejs/12.7.0/firebase-database.js")
            ]);

            // Replace these with your actual Firebase Project credentials
            const firebaseConfig = {
              apiKey: "YOUR_API_KEY",
              projectId: "YOUR_PROJECT_ID",
              databaseURL: "https://YOUR_PROJECT_ID-default-rtdb.firebaseio.com",
              appId: "YOUR_APP_ID"
            };

            const app = fbApp.initializeApp(firebaseConfig, "dynamic_rating_app");
            const db = fbDb.getDatabase(app);
            const dbRef = fbDb.ref(db, "p/" + pathId);

            // Update Firebase safely using transactions
            await fbDb.runTransaction(dbRef, (currentData) => {
              if (currentData) {
                return { t: currentData.t + voteValue, c: currentData.c + 1 };
              } else {
                return { t: voteValue, c: 1 };
              }
            });

            localStorage.setItem("v_" + pathId, Date.now());
            updateStarVisuals(voteValue);
            msgBox.innerText = "Thanks";

            // Disable further clicking
            const starGroup = document.getElementById("_rt_grp");
            starGroup.replaceWith(starGroup.cloneNode(true));

          } catch (error) {
            msgBox.innerText = "Error";
          }
        });
      });
    }
  }, 3500); // 3.5 seconds lazy load delay
});
//]]>
</script>

Firebase Security Rules

To keep your database secure and prevent spam or fake votes, you need to apply the following security rules in your Firebase Realtime Database. These rules allow public read access but strictly validate incoming write requests.

The logic ensures that a new vote only increases the total count (c) by exactly 1, and the total score (t) increases by a valid star rating value (between 1 and 5). This prevents malicious scripts from adding hundreds of votes at once.

{"rules":{"p":{"$i":{".read":true,".write":"!data.exists()||(newData.exists()&&newData.child('c').val()===data.child('c').val()+1&&newData.child('t').val()>=data.child('t').val()+1&&newData.child('t').val()<=data.child('t').val()+5)"}}}}

How It Works

  • Dynamic Path ID: The script grabs the current URL path (window.location.pathname), cleans it up, and uses it as a unique ID for that specific blog post in the Firebase database.
  • Fetching Data: It sends a request to your Cloudflare Worker (/rating?id=...), which retrieves the total rating score (t) and the number of votes (c).
  • Dynamic Import: To keep the initial page load fast, the heavy Firebase SDKs are only imported dynamically import(...) when a user actually clicks a star.
  • Transactions: It uses Firebase's runTransaction method to ensure that if two users vote at the exact same millisecond, both votes are counted accurately without overriding each other.

Pro-Tip: A Warning About SEO and Schema Markup

If you look closely at the JavaScript code, you will notice that it dynamically injects a JSON-LD structured data script for @type: "CreativeWorkSeries" with an AggregateRating.

While this code functions perfectly from a technical standpoint, you must be careful with Google's SEO Guidelines. Google only supports Review Snippets (star ratings in search results) for specific schema types like Books, Recipes, Software Applications, and Products.

Using CreativeWorkSeries on standard informational blog posts, quotes, or general articles just to get stars in search results is considered Structured Data Spam by Google. This can lead to a Manual Action penalty in your Google Search Console.

Best Practice: If your website is a standard blog, it is highly recommended to remove the JSON-LD injection part from the JavaScript code. Use this widget purely for visual user engagement on your front end, keeping your backend schema clean and compliant!

Post a Comment

Write your feedback or openion.