{"id":3280,"date":"2025-09-21T20:55:00","date_gmt":"2025-09-21T20:55:00","guid":{"rendered":"https:\/\/www.antpace.com\/blog\/?p=3280"},"modified":"2025-09-22T20:59:25","modified_gmt":"2025-09-22T20:59:25","slug":"build-a-twitch-widget-for-obs-studio","status":"publish","type":"post","link":"https:\/\/www.antpace.com\/blog\/build-a-twitch-widget-for-obs-studio\/","title":{"rendered":"Build a Twitch Widget for OBS Studio"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">Building My Twitch Viewer Goal Widget<\/h2>\n\n\n\n<p>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\u2019t find anything like it on Etsy, so I thought I\u2019d give it a try.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The First Approach: Server-Side<\/h2>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>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\u2019d need to charge a subscription fee, and I even risked losing money if nobody paid.<\/p>\n\n\n\n<p>The experience wasn\u2019t wasted, though. Configuring SSL on Lightsail was tricky but rewarding. I also had to hunt down a race condition in the <code>server.js<\/code> 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.<\/p>\n\n\n\n<p>Here server file I ran to fetch the user count from Twitch&#8217;s Helix API:<\/p>\n\n\n\n<pre>\nconst express = require(&#x22;express&#x22;);\nconst fetch = require(&#x22;node-fetch&#x22;); \/\/ npm install node-fetch@2\nconst app = express();\nconst PORT = 3000;\n\n\/\/ Read from environment\nconst CLIENT_ID = process.env.TWITCH_CLIENT_ID;\nconst CLIENT_SECRET = process.env.TWITCH_CLIENT_SECRET;\n\nlet ACCESS_TOKEN = &#x22;&#x22;;\n\n\/\/ Get App Access Token\nasync function getAccessToken() {\n  try {\n    const res = await fetch(&#x22;https:\/\/id.twitch.tv\/oauth2\/token&#x22;, {\n      method: &#x22;POST&#x22;,\n      headers: { &#x22;Content-Type&#x22;: &#x22;application\/x-www-form-urlencoded&#x22; },\n      body: &#x60;client_id=${CLIENT_ID}&#x26;client_secret=${CLIENT_SECRET}&#x26;grant_type=client_credentials&#x60;\n    });\n\n    const data = await res.json();\n\n    if (data.access_token) {\n      ACCESS_TOKEN = data.access_token;\n      console.log(&#x22;&#x2705; New access token fetched:&#x22;, ACCESS_TOKEN.slice(0, 10) + &#x22;...&#x22;);\n    } else {\n      console.error(&#x22;&#x274C; Failed to fetch access token:&#x22;, data);\n    }\n\n    return !!ACCESS_TOKEN;\n  } catch (err) {\n    console.error(&#x22;&#x274C; Error fetching access token:&#x22;, err);\n    return false;\n  }\n}\n\n\/\/ Get user_id by username\nasync function getUserId(login) {\n  try {\n    const res = await fetch(&#x60;https:\/\/api.twitch.tv\/helix\/users?login=${login}&#x60;, {\n      headers: {\n        &#x22;Client-ID&#x22;: CLIENT_ID,\n        &#x22;Authorization&#x22;: &#x60;Bearer ${ACCESS_TOKEN}&#x60;\n      }\n    });\n\n    const text = await res.text();  \/\/ raw response\n    let data;\n    try {\n      data = JSON.parse(text);\n    } catch (e) {\n      return { id: null, raw: text, error: &#x22;Failed to parse \/users response&#x22; };\n    }\n\n    if (data.data &#x26;&#x26; data.data.length &#x3E; 0) {\n      return { id: data.data[0].id, raw: text };\n    }\n    return { id: null, raw: text };\n  } catch (err) {\n    return { id: null, raw: null, error: err.message };\n  }\n}\n\n\/\/ Endpoint: \/viewer-count?login=username\napp.get(&#x22;\/viewer-count&#x22;, async (req, res) =&#x3E; {\n  const login = req.query.login;\n  if (!login) return res.status(400).json({ error: &#x22;Missing ?login=username&#x22; });\n\n  if (!ACCESS_TOKEN) {\n    return res.status(503).json({ error: &#x22;Twitch access token not ready&#x22; });\n  }\n\n  try {\n    const { id: userId, raw: rawUserResponse, error: userError } = await getUserId(login);\n\n    if (!userId) {\n      return res.json({\n        viewerCount: 0,\n        status: &#x22;offline&#x22;,\n        userId,\n        login,\n        rawUserResponse,\n        userError,\n        clientId: CLIENT_ID,\n        tokenPrefix: ACCESS_TOKEN ? ACCESS_TOKEN.slice(0, 10) : &#x22;undefined&#x22;\n      });\n    }\n\n    const streamRes = await fetch(&#x60;https:\/\/api.twitch.tv\/helix\/streams?user_id=${userId}&#x60;, {\n      headers: {\n        &#x22;Client-ID&#x22;: CLIENT_ID,\n        &#x22;Authorization&#x22;: &#x60;Bearer ${ACCESS_TOKEN}&#x60;\n      }\n    });\n    const streamText = await streamRes.text();\n    let streamData;\n    try {\n      streamData = JSON.parse(streamText);\n    } catch (e) {\n      return res.json({\n        viewerCount: 0,\n        status: &#x22;offline&#x22;,\n        userId,\n        login,\n        rawUserResponse,\n        rawStreamResponse: streamText,\n        streamError: &#x22;Failed to parse \/streams response&#x22;\n      });\n    }\n\n    if (streamData.data &#x26;&#x26; streamData.data.length &#x3E; 0) {\n      const count = streamData.data[0].viewer_count;\n      return res.json({\n        viewerCount: count,\n        status: &#x22;live&#x22;,\n        userId,\n        login,\n        rawUserResponse,\n        rawStreamResponse: streamData\n      });\n    } else {\n      return res.json({\n        viewerCount: 0,\n        status: &#x22;offline&#x22;,\n        userId,\n        login,\n        rawUserResponse,\n        rawStreamResponse: streamData\n      });\n    }\n  } catch (err) {\n    res.status(500).json({ error: &#x22;Failed to fetch viewer count!&#x22;, details: err.message });\n  }\n});\n\n\/\/ Serve static frontend\napp.use(express.static(&#x22;public&#x22;));\n\n\/\/ Refresh token every hour\nsetInterval(getAccessToken, 60 * 60 * 1000);\n\n\/\/ Start server ONLY after a valid token is fetched\n(async () =&#x3E; {\n  const ok = await getAccessToken();\n  if (ok) {\n    app.listen(PORT, () =&#x3E;\n      console.log(&#x60;&#x1F680; Widget running on http:\/\/localhost:${PORT}&#x60;)\n    );\n  } else {\n    console.error(&#x22;&#x274C; Could not start server: Twitch token fetch failed&#x22;);\n    process.exit(1);\n  }\n})();\n\n\/\/ Config page\napp.get(&#x22;\/config&#x22;, (req, res) =&#x3E; {\n  res.sendFile(__dirname + &#x22;\/public\/config.html&#x22;);\n});\n\n<\/pre>\n\n\n\n<p>And an early version of the front-end HTML file that actually makes up the widget user-interface:<\/p>\n\n\n\n<pre>\n&#x3C;!DOCTYPE html&#x3E;\n&#x3C;html&#x3E;\n&#x3C;head&#x3E;\n  &#x3C;title&#x3E;Twitch Viewer Goal Widget&#x3C;\/title&#x3E;\n  &#x3C;style&#x3E;\n    body {\n      background: transparent;\n      font-family: Arial, sans-serif;\n      text-align: center;\n      margin-top: 50px;\n      color: #fff;\n      overflow: hidden; \/* keep confetti visible but not add scrollbars *\/\n    }\n\n    .goal-square {\n      position: relative;\n      width: 120px;\n      height: 120px;\n      margin: 0 auto;\n      border-radius: 20px;\n      background: #5a3e1b;\n      overflow: hidden;\n      box-shadow: 0 4px 15px rgba(0,0,0,0.4);\n      border: 3px solid #8b5e34;\n      transition: opacity 1s ease-in-out;\n      z-index: 2;\n    }\n\n    .goal-fill-container {\n      position: absolute;\n      bottom: 8px;\n      left: 8px;\n      right: 8px;\n      top: 8px;\n      border-radius: 14px;\n      overflow: hidden;\n      z-index: 1;\n    }\n\n    .goal-fill {\n      position: absolute;\n      bottom: 0;\n      width: 100%;\n      height: 0%;\n      background: linear-gradient(\n        180deg,\n        #fffacd 0%,\n        #ffe135 40%,\n        #f4c542 100%\n      );\n      transition: height 1s ease-in-out;\n      border-radius: 0 0 14px 14px;\n    }\n\n    .sparkle {\n      position: absolute;\n      top: 0;\n      left: -150%;\n      width: 200%;\n      height: 100%;\n      background: linear-gradient(\n        120deg,\n        transparent 0%,\n        rgba(255,255,255,0.3) 50%,\n        transparent 100%\n      );\n      animation: shimmer 15s infinite;\n      z-index: 3;\n      pointer-events: none;\n      opacity: 0;\n    }\n\n    @keyframes shimmer {\n      0%   { transform: translateX(-150%); opacity: 0; }\n      2%   { transform: translateX(-50%);  opacity: 1; }\n      6%   { transform: translateX(100%);  opacity: 0; }\n      100% { transform: translateX(100%);  opacity: 0; }\n    }\n\n    .goal-icon {\n      position: absolute;\n      top: 50%;\n      left: 50%;\n      transform: translate(-50%, -50%);\n      font-size: 18px;\n      font-weight: bold;\n      text-transform: uppercase;\n      color: #ffe135;\n      z-index: 4;\n      animation: breathe 3s ease-in-out infinite;\n      -webkit-text-stroke: 1px #000;\n      text-shadow:\n        1px 1px 2px #000,\n       -1px 1px 2px #000,\n        1px -1px 2px #000,\n       -1px -1px 2px #000;\n    }\n\n    @keyframes breathe {\n      0%, 100% { transform: translate(-50%, -50%) scale(1); }\n      50% { transform: translate(-50%, -50%) scale(1.1); }\n    }\n\n    #status {\n      margin-top: 15px;\n      font-size: 18px;\n      color: #ffe135;\n      text-shadow: 0 1px 2px rgba(0,0,0,0.4);\n      transition: opacity 1s ease-in-out;\n    }\n\n    .hidden {\n      opacity: 0;\n      pointer-events: none;\n    }\n\n    .explode {\n      animation: explodePulse 3s ease-out forwards;\n    }\n\n    @keyframes explodePulse {\n      0%   { transform: scale(1);   box-shadow: 0 0 20px rgba(255,255,0,0.5); }\n      25%  { transform: scale(1.3); box-shadow: 0 0 50px rgba(255,255,0,1); }\n      50%  { transform: scale(1);   box-shadow: 0 0 25px rgba(255,255,0,0.7); }\n      75%  { transform: scale(1.2); box-shadow: 0 0 40px rgba(255,255,0,0.9); }\n      100% { transform: scale(1);   box-shadow: 0 0 15px rgba(255,255,0,0.4); }\n    }\n\n    .confetti {\n      position: fixed;\n      width: 10px;\n      height: 10px;\n      top: 50%;\n      left: 50%;\n      opacity: 0.9;\n      animation: confettiFall 6s ease-out forwards;\n      z-index: 10;\n    }\n\n    @keyframes confettiFall {\n      0%   { transform: translate(0,0) scale(1); opacity: 1; }\n      100% { transform: translate(var(--x), var(--y)) rotate(1440deg) scale(0.5); opacity: 0; }\n    }\n\n    .flash-text {\n      animation: flashText 1s alternate infinite;\n    }\n\n    @keyframes flashText {\n      from { color: #ffe135; }\n      to   { color: #ff4444; }\n    }\n  &#x3C;\/style&#x3E;\n&#x3C;\/head&#x3E;\n&#x3C;body&#x3E;\n  &#x3C;div id=&#x22;widget&#x22;&#x3E;\n    &#x3C;div class=&#x22;goal-square&#x22; id=&#x22;goal-square&#x22;&#x3E;\n      &#x3C;div class=&#x22;goal-fill-container&#x22;&#x3E;\n        &#x3C;div id=&#x22;goal-fill&#x22; class=&#x22;goal-fill&#x22;&#x3E;&#x3C;\/div&#x3E;\n      &#x3C;\/div&#x3E;\n      &#x3C;div class=&#x22;sparkle&#x22;&#x3E;&#x3C;\/div&#x3E;\n      &#x3C;div id=&#x22;goal-icon&#x22; class=&#x22;goal-icon&#x22;&#x3E;Viewer Count Goal&#x3C;\/div&#x3E;\n    &#x3C;\/div&#x3E;\n    &#x3C;div id=&#x22;status&#x22;&#x3E;Checking stream status...&#x3C;\/div&#x3E;\n  &#x3C;\/div&#x3E;\n\n  &#x3C;script&#x3E;\n    \/* === CONFIG CONSTANTS === *\/\n    const CONFIG = {\n      apiEndpoint: &#x22;\/viewer-count&#x22;,\n      pollInterval: 5000,\n      confettiCount: 80,\n      confettiDuration: 6000,\n      explosionDuration: 3000,\n      celebrationDuration: 6500,\n      confettiColors: [&#x22;#ff0&#x22;, &#x22;#f00&#x22;, &#x22;#0f0&#x22;, &#x22;#0ff&#x22;, &#x22;#f0f&#x22;, &#x22;#fff&#x22;]\n    };\n\n    \/* === PARAMS WITH GUARDRAILS === *\/\n    const params = new URLSearchParams(window.location.search);\n\n    let login = params.get(&#x22;login&#x22;) || &#x22;unknown&#x22;; \/\/ demo if missing\n\n    let threshold = parseInt(params.get(&#x22;threshold&#x22;), 10);\n    if (isNaN(threshold) || threshold &#x3C;= 0) threshold = 50; \/\/ safe default\n\n    const hideCount = params.get(&#x22;hideCount&#x22;) === &#x22;true&#x22;; \n\n    let hideUntilPercent = parseInt(params.get(&#x22;hideUntilPercent&#x22;), 10);\n    if (isNaN(hideUntilPercent) || hideUntilPercent &#x3C; 0 || hideUntilPercent &#x3E; 100) {\n      hideUntilPercent = 0;\n    }\n\n    const widgetEl = document.getElementById(&#x22;widget&#x22;);\n    const goalSquare = document.getElementById(&#x22;goal-square&#x22;);\n    const statusEl = document.getElementById(&#x22;status&#x22;);\n\n    let goalReached = false;\n    let demoCount = 0; \/\/ for demo mode\n\n    function triggerExplosion() {\n      goalSquare.classList.add(&#x22;explode&#x22;);\n      setTimeout(() =&#x3E; goalSquare.classList.remove(&#x22;explode&#x22;), CONFIG.explosionDuration);\n\n      const oldText = statusEl.textContent;\n      statusEl.textContent = &#x22;&#x1F389; GOAL REACHED! &#x1F389;&#x22;;\n      statusEl.classList.add(&#x22;flash-text&#x22;);\n\n      for (let i = 0; i &#x3C; CONFIG.confettiCount; i++) {\n        const c = document.createElement(&#x22;div&#x22;);\n        c.className = &#x22;confetti&#x22;;\n        c.style.background = CONFIG.confettiColors[Math.floor(Math.random() * CONFIG.confettiColors.length)];\n        const angle = Math.random() * 2 * Math.PI;\n        const distance = 200 + Math.random() * 300;\n        const x = Math.cos(angle) * distance + &#x22;px&#x22;;\n        const y = Math.sin(angle) * distance + &#x22;px&#x22;;\n        c.style.setProperty(&#x22;--x&#x22;, x);\n        c.style.setProperty(&#x22;--y&#x22;, y);\n        document.body.appendChild(c);\n        setTimeout(() =&#x3E; c.remove(), CONFIG.confettiDuration);\n      }\n\n      setTimeout(() =&#x3E; {\n        statusEl.textContent = oldText;\n        statusEl.classList.remove(&#x22;flash-text&#x22;);\n      }, CONFIG.celebrationDuration);\n    }\n\n    async function fetchViewerData() {\n      \/\/ Demo mode if no login\n      if (login === &#x22;unknown&#x22;) {\n        demoCount += Math.floor(Math.random() * 5) + 1; \/\/ increment by 1&#x2013;5\n        if (demoCount &#x3E; threshold + 10) demoCount = 0;   \/\/ reset after surpassing\n        return { status: &#x22;online&#x22;, viewerCount: demoCount };\n      }\n\n      \/\/ Normal mode\n      const res = await fetch(&#x60;${CONFIG.apiEndpoint}?login=${login}&#x60;);\n      return res.json();\n    }\n\n    async function checkViewers() {\n      try {\n        const data = await fetchViewerData();\n        const fill = document.getElementById(&#x22;goal-fill&#x22;);\n\n        if (data.status === &#x22;offline&#x22;) {\n          statusEl.textContent = &#x60;${login} is offline&#x60;;\n          fill.style.height = &#x22;0%&#x22;;\n          widgetEl.classList.add(&#x22;hidden&#x22;);\n          goalReached = false;\n        } else {\n          const count = data.viewerCount;\n          const progress = Math.min((count \/ threshold) * 100, 100);\n          fill.style.height = &#x60;${progress}%&#x60;;\n\n          if (hideCount) {\n            statusEl.textContent = &#x22;&#x22;;\n          } else {\n            statusEl.textContent = &#x60;${count} \/ ${threshold}&#x60;;\n          }\n\n          widgetEl.classList.toggle(&#x22;hidden&#x22;, progress &#x3C; hideUntilPercent);\n\n          if (progress &#x3E;= 100 &#x26;&#x26; !goalReached) {\n            goalReached = true;\n            triggerExplosion();\n          }\n        }\n      } catch (err) {\n        statusEl.textContent = &#x22;Error loading viewer count&#x22;;\n      }\n    }\n\n    setInterval(checkViewers, CONFIG.pollInterval);\n    checkViewers();\n  &#x3C;\/script&#x3E;\n&#x3C;\/body&#x3E;\n&#x3C;\/html&#x3E;\n\n<\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">A Better Idea: Stand-Alone File<\/h2>\n\n\n\n<p>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\u2019s settings. To do this, I relied on a third-party website (twitchtokengenerator.com) that safely walks them through the process.<\/p>\n\n\n\n<p>This meant I had no recurring costs. My only investment is my time. That\u2019s a big win.<\/p>\n\n\n\n<p>I also moved the widget\u2019s settings out of query parameters and into a simple configuration block inside the file. That made things cleaner and easier for users to customize.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Product Features<\/h2>\n\n\n\n<p>When I packaged the widget for release, I wanted to make sure it wasn\u2019t just functional but also customizable and fun to use. Here are some of the features that made it into the final version:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Viewer Goal Tracking<\/strong> \u2013 The widget dynamically displays your current Twitch viewer count and progress toward a goal you set.<\/li>\n\n\n\n<li><strong>Customizable Goals<\/strong> \u2013 Streamers can easily edit the settings inside the file to choose their own goal number, username, and other preferences.<\/li>\n\n\n\n<li><strong>Visual Themes<\/strong> \u2013 I experimented with playful themes like bananas, moons, and fire, giving streamers a way to match the widget\u2019s style to their stream vibe.<\/li>\n\n\n\n<li><strong>Animated Progress Fill<\/strong> \u2013 As the viewer count grows, the widget smoothly fills up with color, creating a clear and engaging visual effect for the audience.<\/li>\n\n\n\n<li><strong>Celebration Triggers<\/strong> \u2013 When the goal is reached, the widget can display a celebratory effect to highlight the achievement live on stream.<\/li>\n\n\n\n<li><strong>Easy Setup Instructions<\/strong> \u2013 To make onboarding painless, I provided a PDF guide and demo video, along with clear links to generate a Twitch token.<\/li>\n\n\n\n<li><strong>No Server Required<\/strong> \u2013 Because the widget runs completely client-side, there are no recurring costs or subscriptions. Streamers own the file outright after purchase.<\/li>\n<\/ul>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"668\" src=\"https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2025\/09\/donut-1024x668.png\" alt=\"Viewer count goal widget for Twitch\" class=\"wp-image-3290\" srcset=\"https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2025\/09\/donut-1024x668.png 1024w, https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2025\/09\/donut-300x196.png 300w, https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2025\/09\/donut-768x501.png 768w, https:\/\/www.antpace.com\/blog\/wp-content\/uploads\/2025\/09\/donut.png 1064w\" sizes=\"auto, (max-width: 767px) 89vw, (max-width: 1000px) 54vw, (max-width: 1071px) 543px, 580px\" \/><\/figure>\n\n\n\n<p>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.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">User Testing and Iterations<\/h2>\n\n\n\n<p>Once I had a working prototype, I shared it for testing. Immediately, we hit friction.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The instructions had a broken URL.<\/li>\n\n\n\n<li>The token generation process was confusing.<\/li>\n\n\n\n<li>People weren\u2019t sure how to edit the file. Some tried to open it without unzipping first. Others used the wrong text editor.<\/li>\n<\/ul>\n\n\n\n<p>Luckily, my first tester was already familiar with Twitch widgets and gave me some great feedback. For example, instead of providing instructions as a <code>.txt<\/code> file, I switched to a clean PDF guide.<\/p>\n\n\n\n<p>These details matter. If users can\u2019t get through setup, they\u2019ll abandon the product.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Selling the Widget<\/h2>\n\n\n\n<p>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\u2019t have to rely on a third party.<\/p>\n\n\n\n<p>The full package now includes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A <a href=\"https:\/\/www.antpace.com\/twitch-viewer-goal-widget\/instructions\/\">web instructions<\/a> page<\/li>\n\n\n\n<li>A <a href=\"https:\/\/www.antpace.com\/twitch-viewer-goal-widget\/instructions.pdf\" data-type=\"link\" data-id=\"https:\/\/www.antpace.com\/twitch-viewer-goal-widget\/instructions.pdf\" target=\"_blank\" rel=\"noreferrer noopener\">PDF instructions<\/a> file<\/li>\n\n\n\n<li><a href=\"https:\/\/www.antpace.com\/twitch-viewer-goal-widget\/\" data-type=\"link\" data-id=\"https:\/\/www.antpace.com\/twitch-viewer-goal-widget\/instructions\/\">A dedicated landing page<\/a><\/li>\n\n\n\n<li>An <a href=\"https:\/\/www.etsy.com\/listing\/4371454938\/twitch-viewer-count-goal-streaming\" data-type=\"link\" data-id=\"https:\/\/www.etsy.com\/listing\/4371454938\/twitch-viewer-count-goal-streaming\">Etsy listing<\/a><\/li>\n\n\n\n<li>A <a href=\"https:\/\/www.youtube.com\/watch?v=AMJbWwfUGtE&amp;t=5s\">YouTube video demo with setup instructions<\/a> (I invalidated my token afterward, just to be safe)<\/li>\n<\/ul>\n\n\n\n<figure class=\"wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio\"><div class=\"wp-block-embed__wrapper\">\n<iframe loading=\"lazy\" title=\"Twitch Viewer Count Widget - Setup Instructions\" width=\"525\" height=\"295\" src=\"https:\/\/www.youtube.com\/embed\/AMJbWwfUGtE?start=5&#038;feature=oembed\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\" referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen><\/iframe>\n<\/div><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Marketing the Widget<\/h2>\n\n\n\n<p>The marketing plan is straightforward:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Post about it on social media<\/li>\n\n\n\n<li>Share demo videos on YouTube and TikTok<\/li>\n\n\n\n<li>Reach out to small streamers and influencers who might find it useful<\/li>\n\n\n\n<li>Optimize listings on marketplaces like Etsy<\/li>\n<\/ul>\n\n\n\n<p>The idea is to start simple and see where it goes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Looking Back<\/h2>\n\n\n\n<p>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.<\/p>\n\n\n\n<p>Most of all, I had fun building something useful. And who knows? Maybe it will grow into a steady digital product line.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>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\u2019t find anything like it on Etsy, so I thought I\u2019d give it a try. The First Approach: Server-Side At first, I built a version &hellip; <\/p>\n<p class=\"link-more\"><a href=\"https:\/\/www.antpace.com\/blog\/build-a-twitch-widget-for-obs-studio\/\" class=\"more-link\">Continue reading<span class=\"screen-reader-text\"> &#8220;Build a Twitch Widget for OBS Studio&#8221;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":3299,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2,3,4],"tags":[],"class_list":["post-3280","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-design","category-marketing","category-technology"],"_links":{"self":[{"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/posts\/3280","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/comments?post=3280"}],"version-history":[{"count":16,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/posts\/3280\/revisions"}],"predecessor-version":[{"id":3300,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/posts\/3280\/revisions\/3300"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/media\/3299"}],"wp:attachment":[{"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/media?parent=3280"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/categories?post=3280"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.antpace.com\/blog\/wp-json\/wp\/v2\/tags?post=3280"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}