Build a Twitch Widget for OBS Studio

Etsy listing

Building My Twitch Viewer Goal Widget

I enjoy side projects like this. A popular streamer was looking for a widget that would show a viewer count goal on Twitch. She couldn’t find anything like it on Etsy, so I thought I’d give it a try.

The First Approach: Server-Side

At first, I built a version that hit the Twitch API from a Node.js back-end. I spun up an Express server on an AWS Lightsail Node.js blueprint and vibe-coded the whole thing. It worked, but it came with major problems.

The Twitch Helix API requires authentication with both a client ID and a token. These credentials are essentially as sensitive as a password. Running the widget from my own server meant I would have to host those credentials. That made the project impossible to sell as a single-download file on Etsy. To cover costs, I’d need to charge a subscription fee, and I even risked losing money if nobody paid.

The experience wasn’t wasted, though. Configuring SSL on Lightsail was tricky but rewarding. I also had to hunt down a race condition in the server.js file that taught me a lot about debugging async code in Node. Even though I scrapped this approach, it might come in handy for future projects.

Here server file I ran to fetch the user count from Twitch’s Helix API:

const express = require("express");
const fetch = require("node-fetch"); // npm install node-fetch@2
const app = express();
const PORT = 3000;

// Read from environment
const CLIENT_ID = process.env.TWITCH_CLIENT_ID;
const CLIENT_SECRET = process.env.TWITCH_CLIENT_SECRET;

let ACCESS_TOKEN = "";

// Get App Access Token
async function getAccessToken() {
  try {
    const res = await fetch("https://id.twitch.tv/oauth2/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: `client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&grant_type=client_credentials`
    });

    const data = await res.json();

    if (data.access_token) {
      ACCESS_TOKEN = data.access_token;
      console.log("✅ New access token fetched:", ACCESS_TOKEN.slice(0, 10) + "...");
    } else {
      console.error("❌ Failed to fetch access token:", data);
    }

    return !!ACCESS_TOKEN;
  } catch (err) {
    console.error("❌ Error fetching access token:", err);
    return false;
  }
}

// Get user_id by username
async function getUserId(login) {
  try {
    const res = await fetch(`https://api.twitch.tv/helix/users?login=${login}`, {
      headers: {
        "Client-ID": CLIENT_ID,
        "Authorization": `Bearer ${ACCESS_TOKEN}`
      }
    });

    const text = await res.text();  // raw response
    let data;
    try {
      data = JSON.parse(text);
    } catch (e) {
      return { id: null, raw: text, error: "Failed to parse /users response" };
    }

    if (data.data && data.data.length > 0) {
      return { id: data.data[0].id, raw: text };
    }
    return { id: null, raw: text };
  } catch (err) {
    return { id: null, raw: null, error: err.message };
  }
}

// Endpoint: /viewer-count?login=username
app.get("/viewer-count", async (req, res) => {
  const login = req.query.login;
  if (!login) return res.status(400).json({ error: "Missing ?login=username" });

  if (!ACCESS_TOKEN) {
    return res.status(503).json({ error: "Twitch access token not ready" });
  }

  try {
    const { id: userId, raw: rawUserResponse, error: userError } = await getUserId(login);

    if (!userId) {
      return res.json({
        viewerCount: 0,
        status: "offline",
        userId,
        login,
        rawUserResponse,
        userError,
        clientId: CLIENT_ID,
        tokenPrefix: ACCESS_TOKEN ? ACCESS_TOKEN.slice(0, 10) : "undefined"
      });
    }

    const streamRes = await fetch(`https://api.twitch.tv/helix/streams?user_id=${userId}`, {
      headers: {
        "Client-ID": CLIENT_ID,
        "Authorization": `Bearer ${ACCESS_TOKEN}`
      }
    });
    const streamText = await streamRes.text();
    let streamData;
    try {
      streamData = JSON.parse(streamText);
    } catch (e) {
      return res.json({
        viewerCount: 0,
        status: "offline",
        userId,
        login,
        rawUserResponse,
        rawStreamResponse: streamText,
        streamError: "Failed to parse /streams response"
      });
    }

    if (streamData.data && streamData.data.length > 0) {
      const count = streamData.data[0].viewer_count;
      return res.json({
        viewerCount: count,
        status: "live",
        userId,
        login,
        rawUserResponse,
        rawStreamResponse: streamData
      });
    } else {
      return res.json({
        viewerCount: 0,
        status: "offline",
        userId,
        login,
        rawUserResponse,
        rawStreamResponse: streamData
      });
    }
  } catch (err) {
    res.status(500).json({ error: "Failed to fetch viewer count!", details: err.message });
  }
});

// Serve static frontend
app.use(express.static("public"));

// Refresh token every hour
setInterval(getAccessToken, 60 * 60 * 1000);

// Start server ONLY after a valid token is fetched
(async () => {
  const ok = await getAccessToken();
  if (ok) {
    app.listen(PORT, () =>
      console.log(`🚀 Widget running on http://localhost:${PORT}`)
    );
  } else {
    console.error("❌ Could not start server: Twitch token fetch failed");
    process.exit(1);
  }
})();

// Config page
app.get("/config", (req, res) => {
  res.sendFile(__dirname + "/public/config.html");
});

And an early version of the front-end HTML file that actually makes up the widget user-interface:

<!DOCTYPE html>
<html>
<head>
  <title>Twitch Viewer Goal Widget</title>
  <style>
    body {
      background: transparent;
      font-family: Arial, sans-serif;
      text-align: center;
      margin-top: 50px;
      color: #fff;
      overflow: hidden; /* keep confetti visible but not add scrollbars */
    }

    .goal-square {
      position: relative;
      width: 120px;
      height: 120px;
      margin: 0 auto;
      border-radius: 20px;
      background: #5a3e1b;
      overflow: hidden;
      box-shadow: 0 4px 15px rgba(0,0,0,0.4);
      border: 3px solid #8b5e34;
      transition: opacity 1s ease-in-out;
      z-index: 2;
    }

    .goal-fill-container {
      position: absolute;
      bottom: 8px;
      left: 8px;
      right: 8px;
      top: 8px;
      border-radius: 14px;
      overflow: hidden;
      z-index: 1;
    }

    .goal-fill {
      position: absolute;
      bottom: 0;
      width: 100%;
      height: 0%;
      background: linear-gradient(
        180deg,
        #fffacd 0%,
        #ffe135 40%,
        #f4c542 100%
      );
      transition: height 1s ease-in-out;
      border-radius: 0 0 14px 14px;
    }

    .sparkle {
      position: absolute;
      top: 0;
      left: -150%;
      width: 200%;
      height: 100%;
      background: linear-gradient(
        120deg,
        transparent 0%,
        rgba(255,255,255,0.3) 50%,
        transparent 100%
      );
      animation: shimmer 15s infinite;
      z-index: 3;
      pointer-events: none;
      opacity: 0;
    }

    @keyframes shimmer {
      0%   { transform: translateX(-150%); opacity: 0; }
      2%   { transform: translateX(-50%);  opacity: 1; }
      6%   { transform: translateX(100%);  opacity: 0; }
      100% { transform: translateX(100%);  opacity: 0; }
    }

    .goal-icon {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-size: 18px;
      font-weight: bold;
      text-transform: uppercase;
      color: #ffe135;
      z-index: 4;
      animation: breathe 3s ease-in-out infinite;
      -webkit-text-stroke: 1px #000;
      text-shadow:
        1px 1px 2px #000,
       -1px 1px 2px #000,
        1px -1px 2px #000,
       -1px -1px 2px #000;
    }

    @keyframes breathe {
      0%, 100% { transform: translate(-50%, -50%) scale(1); }
      50% { transform: translate(-50%, -50%) scale(1.1); }
    }

    #status {
      margin-top: 15px;
      font-size: 18px;
      color: #ffe135;
      text-shadow: 0 1px 2px rgba(0,0,0,0.4);
      transition: opacity 1s ease-in-out;
    }

    .hidden {
      opacity: 0;
      pointer-events: none;
    }

    .explode {
      animation: explodePulse 3s ease-out forwards;
    }

    @keyframes explodePulse {
      0%   { transform: scale(1);   box-shadow: 0 0 20px rgba(255,255,0,0.5); }
      25%  { transform: scale(1.3); box-shadow: 0 0 50px rgba(255,255,0,1); }
      50%  { transform: scale(1);   box-shadow: 0 0 25px rgba(255,255,0,0.7); }
      75%  { transform: scale(1.2); box-shadow: 0 0 40px rgba(255,255,0,0.9); }
      100% { transform: scale(1);   box-shadow: 0 0 15px rgba(255,255,0,0.4); }
    }

    .confetti {
      position: fixed;
      width: 10px;
      height: 10px;
      top: 50%;
      left: 50%;
      opacity: 0.9;
      animation: confettiFall 6s ease-out forwards;
      z-index: 10;
    }

    @keyframes confettiFall {
      0%   { transform: translate(0,0) scale(1); opacity: 1; }
      100% { transform: translate(var(--x), var(--y)) rotate(1440deg) scale(0.5); opacity: 0; }
    }

    .flash-text {
      animation: flashText 1s alternate infinite;
    }

    @keyframes flashText {
      from { color: #ffe135; }
      to   { color: #ff4444; }
    }
  </style>
</head>
<body>
  <div id="widget">
    <div class="goal-square" id="goal-square">
      <div class="goal-fill-container">
        <div id="goal-fill" class="goal-fill"></div>
      </div>
      <div class="sparkle"></div>
      <div id="goal-icon" class="goal-icon">Viewer Count Goal</div>
    </div>
    <div id="status">Checking stream status...</div>
  </div>

  <script>
    /* === CONFIG CONSTANTS === */
    const CONFIG = {
      apiEndpoint: "/viewer-count",
      pollInterval: 5000,
      confettiCount: 80,
      confettiDuration: 6000,
      explosionDuration: 3000,
      celebrationDuration: 6500,
      confettiColors: ["#ff0", "#f00", "#0f0", "#0ff", "#f0f", "#fff"]
    };

    /* === PARAMS WITH GUARDRAILS === */
    const params = new URLSearchParams(window.location.search);

    let login = params.get("login") || "unknown"; // demo if missing

    let threshold = parseInt(params.get("threshold"), 10);
    if (isNaN(threshold) || threshold <= 0) threshold = 50; // safe default

    const hideCount = params.get("hideCount") === "true"; 

    let hideUntilPercent = parseInt(params.get("hideUntilPercent"), 10);
    if (isNaN(hideUntilPercent) || hideUntilPercent < 0 || hideUntilPercent > 100) {
      hideUntilPercent = 0;
    }

    const widgetEl = document.getElementById("widget");
    const goalSquare = document.getElementById("goal-square");
    const statusEl = document.getElementById("status");

    let goalReached = false;
    let demoCount = 0; // for demo mode

    function triggerExplosion() {
      goalSquare.classList.add("explode");
      setTimeout(() => goalSquare.classList.remove("explode"), CONFIG.explosionDuration);

      const oldText = statusEl.textContent;
      statusEl.textContent = "🎉 GOAL REACHED! 🎉";
      statusEl.classList.add("flash-text");

      for (let i = 0; i < CONFIG.confettiCount; i++) {
        const c = document.createElement("div");
        c.className = "confetti";
        c.style.background = CONFIG.confettiColors[Math.floor(Math.random() * CONFIG.confettiColors.length)];
        const angle = Math.random() * 2 * Math.PI;
        const distance = 200 + Math.random() * 300;
        const x = Math.cos(angle) * distance + "px";
        const y = Math.sin(angle) * distance + "px";
        c.style.setProperty("--x", x);
        c.style.setProperty("--y", y);
        document.body.appendChild(c);
        setTimeout(() => c.remove(), CONFIG.confettiDuration);
      }

      setTimeout(() => {
        statusEl.textContent = oldText;
        statusEl.classList.remove("flash-text");
      }, CONFIG.celebrationDuration);
    }

    async function fetchViewerData() {
      // Demo mode if no login
      if (login === "unknown") {
        demoCount += Math.floor(Math.random() * 5) + 1; // increment by 1–5
        if (demoCount > threshold + 10) demoCount = 0;   // reset after surpassing
        return { status: "online", viewerCount: demoCount };
      }

      // Normal mode
      const res = await fetch(`${CONFIG.apiEndpoint}?login=${login}`);
      return res.json();
    }

    async function checkViewers() {
      try {
        const data = await fetchViewerData();
        const fill = document.getElementById("goal-fill");

        if (data.status === "offline") {
          statusEl.textContent = `${login} is offline`;
          fill.style.height = "0%";
          widgetEl.classList.add("hidden");
          goalReached = false;
        } else {
          const count = data.viewerCount;
          const progress = Math.min((count / threshold) * 100, 100);
          fill.style.height = `${progress}%`;

          if (hideCount) {
            statusEl.textContent = "";
          } else {
            statusEl.textContent = `${count} / ${threshold}`;
          }

          widgetEl.classList.toggle("hidden", progress < hideUntilPercent);

          if (progress >= 100 && !goalReached) {
            goalReached = true;
            triggerExplosion();
          }
        }
      } catch (err) {
        statusEl.textContent = "Error loading viewer count";
      }
    }

    setInterval(checkViewers, CONFIG.pollInterval);
    checkViewers();
  </script>
</body>
</html>

A Better Idea: Stand-Alone File

The real breakthrough came when I decided to cut out the server entirely. Instead of me hosting credentials, I let users generate their own. They paste their Twitch token and client ID directly into the widget’s settings. To do this, I relied on a third-party website (twitchtokengenerator.com) that safely walks them through the process.

This meant I had no recurring costs. My only investment is my time. That’s a big win.

I also moved the widget’s settings out of query parameters and into a simple configuration block inside the file. That made things cleaner and easier for users to customize.

Product Features

When I packaged the widget for release, I wanted to make sure it wasn’t just functional but also customizable and fun to use. Here are some of the features that made it into the final version:

  • Viewer Goal Tracking – The widget dynamically displays your current Twitch viewer count and progress toward a goal you set.
  • Customizable Goals – Streamers can easily edit the settings inside the file to choose their own goal number, username, and other preferences.
  • Visual Themes – I experimented with playful themes like bananas, moons, and fire, giving streamers a way to match the widget’s style to their stream vibe.
  • Animated Progress Fill – As the viewer count grows, the widget smoothly fills up with color, creating a clear and engaging visual effect for the audience.
  • Celebration Triggers – When the goal is reached, the widget can display a celebratory effect to highlight the achievement live on stream.
  • Easy Setup Instructions – To make onboarding painless, I provided a PDF guide and demo video, along with clear links to generate a Twitch token.
  • No Server Required – Because the widget runs completely client-side, there are no recurring costs or subscriptions. Streamers own the file outright after purchase.
Viewer count goal widget for Twitch

These features make the widget simple enough for beginners to set up, but flexible enough for experienced streamers to customize and show off during their broadcasts.

User Testing and Iterations

Once I had a working prototype, I shared it for testing. Immediately, we hit friction.

  • The instructions had a broken URL.
  • The token generation process was confusing.
  • People weren’t sure how to edit the file. Some tried to open it without unzipping first. Others used the wrong text editor.

Luckily, my first tester was already familiar with Twitch widgets and gave me some great feedback. For example, instead of providing instructions as a .txt file, I switched to a clean PDF guide.

These details matter. If users can’t get through setup, they’ll abandon the product.

Selling the Widget

I decided to sell the widget as a digital download. At first, I planned to use Etsy for its built-in marketplace and discoverability. But I also set up direct sales on my own website with Stripe. That way I control the checkout experience and don’t have to rely on a third party.

The full package now includes:

Marketing the Widget

The marketing plan is straightforward:

  • Post about it on social media
  • Share demo videos on YouTube and TikTok
  • Reach out to small streamers and influencers who might find it useful
  • Optimize listings on marketplaces like Etsy

The idea is to start simple and see where it goes.

Looking Back

This project taught me a lot about balancing technical decisions with business models. Building a server-side widget was fun and educational, but not sustainable. The stand-alone approach keeps things lightweight, low-cost, and sellable.

Most of all, I had fun building something useful. And who knows? Maybe it will grow into a steady digital product line.

Content Creation, Branding, & Community Building

Content creation

I’ve created blog content for a long time, here and other places. I’ve also contributed to the collective cloud called social media throughout my so-far short life. These posts have ranged from philosophical to instructional, and woven with stories and real experiences.

Over the years, this website has worn many hats: a personal site, a freelance portfolio, a solo agency brand, a resume, a place to try stuff. It’s always been experimental. I write about programming, about writing, and about my experiences. Some non-blog pages on this website feel very “marketing” but dually have helped me to more clearly define what services I can help people with.

I really want to make a shift to emphasize that this is a “personal brand” website, and not necessarily a commercial one. My consulting services are provided as a sole-proprietorship under my personal brand. Launching a consulting agency brand to present a more “business-first” appearance to potential clients is a possibility, but is not what this website is.

Video

I’ve published some YouTube content, for instructional blogs and product tutorials. I haven’t done much vlog or podcast style talking videos. Instead of strictly writing, I think adding video companions to make these posts more multi-media is a step in the right directions. I’ve thought about going back to existing posts and adding a companion video as a way to get started. I’m especially interested in streaming on a platform like Twitch. I could record myself doing computer projects and yapping about it. Then, blog posts like these could be “show notes” for each VOD.

Community

The great thing about publishing content is the community it can build. In a lot of ways, building a brand is building a community around it. I think that is even true for a personal brand. I’d like the content I create to be a launch pad for community.


This site is a sandbox, a signal, and a snapshot. It changes with me. I just want to document what I’m learning, thinking, and building in real time. Whether it’s code, ideas, personal growth, or experiments with new formats, this is where it all lands. If any of it resonates with you, I hope you’ll stick around, and say “hi”.