<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Cristina Rodriguez</title>
    <description>The latest articles on DEV Community by Cristina Rodriguez (@yosolita1978).</description>
    <link>https://dev.to/yosolita1978</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg</url>
      <title>DEV Community: Cristina Rodriguez</title>
      <link>https://dev.to/yosolita1978</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/yosolita1978"/>
    <language>en</language>
    <item>
      <title>Your MCP server works fine. Then you run a second copy, and the next call lands somewhere that has never heard of the first one. I wrote up what breaks, what MCP 2026-07-28 actually changed, and the bug I found in my own code while writing it.</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Fri, 31 Jul 2026 16:41:09 +0000</pubDate>
      <link>https://dev.to/yosolita1978/your-mcp-server-works-fine-then-you-run-a-second-copy-and-the-next-call-lands-somewhere-that-has-4i5f</link>
      <guid>https://dev.to/yosolita1978/your-mcp-server-works-fine-then-you-run-a-second-copy-and-the-next-call-lands-somewhere-that-has-4i5f</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/yosolita1978/what-breaks-when-one-mcp-server-becomes-two-1853" class="crayons-story__hidden-navigation-link"&gt;What Breaks When One MCP Server Becomes Two&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/yosolita1978" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" alt="yosolita1978 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/yosolita1978" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Cristina Rodriguez
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Cristina Rodriguez
                
              
              &lt;div id="story-author-preview-content-4282265" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/yosolita1978" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Cristina Rodriguez&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/yosolita1978/what-breaks-when-one-mcp-server-becomes-two-1853" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 31&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/yosolita1978/what-breaks-when-one-mcp-server-becomes-two-1853" id="article-link-4282265"&gt;
          What Breaks When One MCP Server Becomes Two
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/mcp"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;mcp&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/python"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;python&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/beginners"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;beginners&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/yosolita1978/what-breaks-when-one-mcp-server-becomes-two-1853" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;1&lt;span class="hidden s:inline"&gt;&amp;nbsp;reaction&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/yosolita1978/what-breaks-when-one-mcp-server-becomes-two-1853#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            13 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>architecture</category>
      <category>backend</category>
      <category>mcp</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>What Breaks When One MCP Server Becomes Two</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Fri, 31 Jul 2026 16:39:01 +0000</pubDate>
      <link>https://dev.to/yosolita1978/what-breaks-when-one-mcp-server-becomes-two-1853</link>
      <guid>https://dev.to/yosolita1978/what-breaks-when-one-mcp-server-becomes-two-1853</guid>
      <description>&lt;p&gt;I didn't start this because MCP is trendy. I started it because I could see the problem coming.&lt;/p&gt;

&lt;p&gt;Anzuelo, a lead-finding agent I built, scrapes Reddit, Hacker News, Bluesky, Mastodon, YouTube, LinkedIn, and Threads looking for people talking about the things I care about, then scores what it finds using the Claude API. Right now it runs as a scheduled job, once a day, one process, no concurrency to worry about. But the moment I, or anyone, wants to expose that kind of search-and-page workflow as something other tools can call on demand, "the server remembers where you left off" stops being a safe assumption. Run two instances behind a load balancer and the second call can land on a machine that's never heard of the first one.&lt;/p&gt;

&lt;p&gt;That's not an MCP problem specifically. It's a distributed systems problem, and it's the direction MCP's July 28 spec is pushing: stop relying on one instance's memory to connect one call to the next. The state can still live somewhere. What changes is that the client carries something explicit to point at it, instead of the server assuming the next call comes back to the same process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who's talking to who
&lt;/h2&gt;

&lt;p&gt;Quick setup, because the rest of this doesn't land without it.&lt;/p&gt;

&lt;p&gt;There's a model, meaning the assistant someone is chatting with. There's an MCP server, which is a small program you write that offers up a few functions the model is allowed to call. Those functions are called tools. And there's a client sitting in the middle, carrying messages between the two.&lt;/p&gt;

&lt;p&gt;When the model wants to search your mentions, it never runs your code. It sends a message saying "call start_search, keyword is mcp." The client hands that to your server. Your server runs the function and sends text back. The model reads that text the same way it reads anything else in the conversation.&lt;/p&gt;

&lt;p&gt;The part that matters for this post: each of those is a separate round trip. Ask, answer, done. Nothing in that arrangement promises the next question arrives at the same running copy of your server.&lt;/p&gt;

&lt;p&gt;Before we go further, one thing I should be straight about. Anzuelo is why I care about this problem, and Anzuelo is also not what I'm showing you. It doesn't speak MCP yet. No server, no tools, nobody calling in. So I built a tiny MCP server that does nothing except search a list and page through it, because I wanted to watch this failure happen somewhere small before I built it on top of data I'd actually have to care about. What follows is the old way, where the server remembers your search for you, and the new way, where every call carries what it needs. I'm writing it in the order things actually happened, including the three places I turned out to be wrong.&lt;/p&gt;

&lt;p&gt;Here's what that gap actually looks like for a system like Anzuelo, running today versus what it would need before other people's clients could call it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgoh3i1g6aznaupp13zvy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgoh3i1g6aznaupp13zvy.png" alt="Anzuelo today versus what it would need in production: today it runs as a cron trigger once a day, in one process with shared memory, searching and scoring via the Claude API and storing leads in Supabase. Before other tools could call it, it would need to be stateless with no reliance on instance-local memory, use an explicit handle carrying an ID and cursor in the arguments, let any copy answer with no sticky routing, and authenticate clients with an OAuth token." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want the longer version of that, &lt;a href="https://dev.to/yosolita1978/okay-but-what-is-mcp-no-really-3nmk"&gt;I wrote a plain-language intro a while back&lt;/a&gt; covering what problem it solves and how tool calls actually work. This post picks up from there and goes straight into the part that's changing. (&lt;a href="https://github.com/Yosolita1978/Anzuelo" rel="noopener noreferrer"&gt;Anzuelo's searcher-per-platform architecture&lt;/a&gt; is where I first ran into this kind of state-management question, even without MCP in the mix.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're a career-changer aiming for an entry-level developer role&lt;/strong&gt;, this is worth your time for a reason that has nothing to do with chasing a trend. Reasoning about state and designing a clean contract between two systems is what interviewers have always tested, and MCP is a well-documented place to practice it right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that shows up when one process becomes two
&lt;/h2&gt;

&lt;p&gt;One more word before the story. When a server gets busy, you run several copies of it at once and spread traffic across them. Kubernetes people call each copy a pod, and I'll use that word because it's short. Read it as "one running copy of my server, sitting on some machine."&lt;/p&gt;

&lt;p&gt;Picture two identical copies of the same MCP server running behind a load balancer: call them pod A and pod B. A client sends &lt;code&gt;start_search&lt;/code&gt;, the request lands on pod A, and pod A stores the search progress in its own memory. The client's next call, &lt;code&gt;get_next_page&lt;/code&gt;, has a coin-flip chance of landing on pod B instead. Pod B has never heard of this search. It doesn't crash. It politely tells the client there's no active search, which is a dead end dressed up as a successful response.&lt;/p&gt;

&lt;p&gt;This isn't a hypothetical. It was the most common failure story people wrote about in the run-up to July 28. And it was fixable before the spec even shipped, because the fix isn't really about MCP. It's about not trusting a specific process to remember something a specific client needs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fucdqolyh7ix36gf29i0m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fucdqolyh7ix36gf29i0m.png" alt="An MCP client sends two requests through a load balancer to two copies of the same server. Pod A handles request 1, start_search, and saves cursor 0 in its own memory. Pod B handles request 2, get_next_page, but has no affinity and no shared state, so it replies that there is no active search instead of returning the next result. Same client, same load balancer, and pod B never saw request 1." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What 2026-07-28 actually changed
&lt;/h2&gt;

&lt;p&gt;Before this release, an MCP conversation started with a hello. The client and the server traded a message called &lt;code&gt;initialize&lt;/code&gt;, agreed on which version they were both speaking and what each one could do, and from then on the server kept a thread running underneath every later call. That thread was called the session.&lt;/p&gt;

&lt;p&gt;7-28 removes the hello and the thread. Every request now shows up carrying its own version, its own information about who is calling, and its own list of what the client can do. Nothing gets agreed once and remembered.&lt;/p&gt;

&lt;p&gt;That is more baggage on every single call. What you get for it is that no request depends on reaching the same copy of your server as the last one did. The load balancer can send traffic wherever it likes. Your server can also run in places that never keep a connection open, which is most of the cheap places to run things.&lt;/p&gt;

&lt;p&gt;Three other changes came with it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Signing in got more standard. Auth now matches the way OAuth is normally set up, so a server can plug into whatever identity system a company already uses instead of inventing something.&lt;/li&gt;
&lt;li&gt;New features can ship as add-ons now, without changing the protocol itself. Two arrived with this release. MCP Apps lets a server send back a small interactive interface instead of plain text. Tasks covers work that takes a while to finish.&lt;/li&gt;
&lt;li&gt;Every feature got a promise. Once something is marked deprecated, it stays in the spec for at least twelve more months before it can be removed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tasks is the one worth a look here. When a job takes a while, the server answers with a task handle, and the client uses that handle to come back and ask how it's going. That's the same move I make below with &lt;code&gt;search_id&lt;/code&gt; and a cursor. The client holds onto something small and hands it back on the next call.&lt;/p&gt;

&lt;p&gt;And if you already have a server running, it didn't break on July 28. New clients that speak 7-28 fall back to the old hello when they reach an older server.&lt;/p&gt;

&lt;p&gt;One thing I want to be careful about, because I got it wrong in my first draft. The spec removed the session the protocol was keeping for you. It did not remove the dictionary I was keeping in my own server. No spec can do that. That one was always mine to fix. What 7-28 does is take away the crutch.&lt;/p&gt;

&lt;p&gt;The full release notes are on &lt;a href="https://blog.modelcontextprotocol.io/posts/2026-07-28/" rel="noopener noreferrer"&gt;the MCP blog&lt;/a&gt;, and AAIF published &lt;a href="https://aaif.io/blog/mcp-2026-07-28-whats-changing-and-how-to-migrate" rel="noopener noreferrer"&gt;a migration guide&lt;/a&gt; that goes deeper than I do here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Old way: state lives in the server's memory
&lt;/h2&gt;

&lt;p&gt;Here's a tiny mention-tracker server, written the naive way, with the cross-call state parked in one process. This isn't what the 2025-11-25 spec told you to build. It's what's easy to reach for when you're on your laptop, one process, one client, and nothing has ever gone wrong. It searches a list of mock mentions for a keyword and lets you page through results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mention-tracker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;MOCK_MENTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reddit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anyone using agentgateway in prod?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP stateless core is a good call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bluesky&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;just shipped an MCP server, no auth yet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mastodon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agentic tools are eating my weekend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;youtube&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tutorial: build an MCP client in 10 min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# One shared slot for this whole process. Not scoped to a session or a caller.
# Whoever calls last wins, and a second caller mid-search clobbers the first.
&lt;/span&gt;&lt;span class="n"&gt;_search_state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Start a mention search. Progress is remembered server-side.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MOCK_MENTIONS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="n"&gt;_search_state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matches&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Search started for &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;. Found &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; mention&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;. Call get_next_page() to page through results.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_next_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return the next mention. Trusts the server to remember where we left off.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_search_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No active search. Call start_search first.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;matches&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No more mentions.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;mention&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;mention&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;mention&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;get_next_page()&lt;/code&gt; takes zero arguments. It only works because this exact process remembers the last cursor. A cursor is a bookmark, a number saying how far through the results you've already read. The state here is worse than session state, and I'll come back to why once you've seen the wire calls. Here's the actual JSON-RPC exchange this demo sends, captured over stdio. JSON-RPC is just the message format MCP uses: a function name, some arguments, a reply. And stdio means the client and the server are talking through the terminal, one process to one process, which is how you run this on a laptop. So this is one client talking to a single process, and it isn't reproducing the pod A and pod B routing. What it shows is the thing that makes that routing dangerous:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;start_search&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"keyword"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Search started for 'mcp'. Found 3 mentions. Call get_next_page() to page through results."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"structuredContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Search started for 'mcp'. Found 3 mentions. Call get_next_page() to page through results."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isError"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;get_next_page&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[hn] MCP stateless core is a good call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"structuredContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[hn] MCP stateless core is a good call"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isError"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that second call: &lt;code&gt;"arguments": {}&lt;/code&gt;. Nothing in it identifies the search. The only reason it can answer is that this process still holds the cursor from the first call. Point it at a process that didn't serve &lt;code&gt;start_search&lt;/code&gt; and there is nothing to page through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Surprise one: it was worse than session state
&lt;/h3&gt;

&lt;p&gt;I noticed this while I was writing the old server, and it bothered me more than the load balancer story did. &lt;code&gt;_search_state["current"]&lt;/code&gt; isn't scoped to a session or a caller. It's one slot for the entire process, keyed on the literal string &lt;code&gt;"current"&lt;/code&gt;. Session-pinning means the load balancer keeps sending the same client back to the same copy. That at least guarantees that your requests keep landing on the pod that knows about &lt;em&gt;your&lt;/em&gt; search. This doesn't even give you that: two clients hitting the same process share the slot, so whoever calls &lt;code&gt;start_search&lt;/code&gt; last wins and the other one's cursor silently jumps into someone else's result set. The load balancer story is the failure you notice. This one you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  New way: state travels with the call
&lt;/h2&gt;

&lt;p&gt;So I rebuilt the server, and the first thing I had to settle was what the client would carry between calls.&lt;/p&gt;

&lt;h3&gt;
  
  
  Surprise two: I had to decide what a handle even is
&lt;/h3&gt;

&lt;p&gt;Here's the same server, rebuilt so the continuation state travels in the arguments instead of living in the process. Writing it, I hit a question I hadn't planned for: what is the handle actually made of? Somebody has to decide, and that somebody was me. I picked the keyword. &lt;code&gt;search_id&lt;/code&gt; is just the string the caller searched for, and the server recomputes matches from a static list on every call. That's a demo answer and I wasn't comfortable with it. A real one would mint an opaque ID, keep the result set somewhere every instance can read, and validate the ID before touching it. I left the simple version in anyway, because what I want to show is where the continuity lives, and that part is the same in both. I didn't wait for the spec release to write this, and you don't need to either. It works in any current MCP SDK.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TypedDict&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;

&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mention-tracker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;MOCK_MENTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reddit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anyone using agentgateway in prod?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MCP stateless core is a good call&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bluesky&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;just shipped an MCP server, no auth yet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mastodon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agentic tools are eating my weekend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;youtube&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tutorial: build an MCP client in 10 min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SearchHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypedDict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;search_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;match_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MentionPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypedDict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;


&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SearchHandle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Start a mention search. Returns a handle the CLIENT carries forward.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MOCK_MENTIONS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;match_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Search started for &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;. Found &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; mention&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;!&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_next_page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MentionPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return the next mention. search_id and cursor are explicit arguments,
    so this call is self-contained. Any instance can answer it.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MOCK_MENTIONS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;search_id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;mention&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mention&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mention&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same tool names. The model calls them the same way it called the old ones, though the contract underneath has changed: &lt;code&gt;get_next_page&lt;/code&gt; now requires two arguments it used to pull from memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Surprise three: the handle came back as a string
&lt;/h3&gt;

&lt;p&gt;The old server annotated &lt;code&gt;start_search&lt;/code&gt; as &lt;code&gt;-&amp;gt; str&lt;/code&gt;. Its reply carried a populated &lt;code&gt;structuredContent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"structuredContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Search started for 'mcp'. Found 3 mentions. Call get_next_page() to page through results."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rebuilt server annotated the same tool as &lt;code&gt;-&amp;gt; dict&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its reply carried this instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"structuredContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the version I was presenting as the improvement produced the worse-shaped output. The handle existed only as text inside &lt;code&gt;content&lt;/code&gt;, and a client would have to parse that string before it could read a single field.&lt;/p&gt;

&lt;p&gt;The cause is the annotation. A bare &lt;code&gt;dict&lt;/code&gt; declares no key names and no types, so FastMCP has no schema to build from and falls back to dumping the return value as text.&lt;/p&gt;

&lt;p&gt;The fix is declaring the shape. With &lt;code&gt;SearchHandle&lt;/code&gt; and &lt;code&gt;MentionPage&lt;/code&gt; as TypedDicts, on SDK 1.29.0, the same call returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"structuredContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"search_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"match_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Search started for 'mcp'. Found 3 mentions."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One difference worth flagging: the old server wraps its value under a &lt;code&gt;result&lt;/code&gt; key, while the TypedDict version returns the fields as a bare object.&lt;/p&gt;

&lt;p&gt;The part worth noticing is what &lt;code&gt;-&amp;gt; dict&lt;/code&gt; allowed. That annotation let &lt;code&gt;get_next_page&lt;/code&gt; return two different shapes depending on the branch: the finished branch returned two keys, &lt;code&gt;done&lt;/code&gt; and &lt;code&gt;cursor&lt;/code&gt;, and the normal branch returned four. Declaring &lt;code&gt;MentionPage&lt;/code&gt; forced one consistent shape, with &lt;code&gt;platform&lt;/code&gt; and &lt;code&gt;text&lt;/code&gt; null when the search is done.&lt;/p&gt;

&lt;p&gt;Here's the wire exchange from the finished version, with both shapes declared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;start_search&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"keyword"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;search_id&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;mcp&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;cursor&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: 0,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;match_count&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: 3,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Search started for 'mcp'. Found 3 mentions.&lt;/span&gt;&lt;span class="se"&gt;\"\n&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"structuredContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"search_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"match_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Search started for 'mcp'. Found 3 mentions."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isError"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;get_next_page&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"search_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;platform&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;hn&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;MCP stateless core is a good call&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;cursor&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: 1,&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;done&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;: false&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"annotations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"structuredContent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MCP stateless core is a good call"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isError"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is stored in this process. Pod A or pod B, it doesn't matter, &lt;code&gt;search_id&lt;/code&gt; and &lt;code&gt;cursor&lt;/code&gt; are right there in the arguments. The model carries the handle forward the same way it would carry any other tool result. A production server would still have state behind that handle, sitting in Postgres or Redis where every instance can reach it. What moved is the assumption. The server stops betting that the next call comes back to the same memory. That's the core of the migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't fix (yet), and what still works
&lt;/h2&gt;

&lt;p&gt;This toy server sidesteps things a real production server would need. There's more to handle around routing and auth, and the spec covers it. Roots, Sampling, and Logging are deprecated in this release, so if you're running them, nothing broke on July 28 and you have the twelve-month window to move.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;The full code for both versions is in &lt;a href="https://github.com/Yosolita1978/mention-tracker" rel="noopener noreferrer"&gt;this repo&lt;/a&gt;. One install note. Both servers were built and tested on mcp 1.29.0. Version 2.x renames FastMCP to MCPServer. The decorator API survives the rename, but the import path does not, so an unpinned install will fail before it gets that far. Clone it and run the old version in two terminal windows. Two terminals means two processes with separate memory, which is the same thing a load balancer does to you by accident. Call &lt;code&gt;start_search&lt;/code&gt; in one and &lt;code&gt;get_next_page&lt;/code&gt; in the other, and watch it come back with "No active search. Call start_search first." That's a dead end, not a crash, but useless to the client that expected its next mention. Then run the new version and watch it not care which "pod" answers.&lt;/p&gt;

&lt;p&gt;On a real server with real data I'd do three things differently. The handle would be an opaque ID, not the keyword, so it doesn't leak what was searched and can't be guessed. The result set behind it would live somewhere every copy of the server can read. And &lt;code&gt;get_next_page&lt;/code&gt; would validate the ID before it touched anything, instead of trusting whatever arrived in the arguments.&lt;/p&gt;

&lt;p&gt;If you're new to backend development and wondering whether any of this applies to you: it's the same lesson every distributed system eventually teaches, MCP is just handing it to you early, with good docs and a spec you can go read.&lt;/p&gt;

&lt;p&gt;The next one is the real thing. Anzuelo is getting an MCP server, with actual mentions in Supabase and actual people paging through them, and I'll write up what breaks when the data stops being five hardcoded strings.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>python</category>
      <category>beginners</category>
    </item>
    <item>
      <title>So, you know how to connect to an API endpoint? Great. Now they're asking you to build an MCP, and you're freaking out. Don't sweat it, it's simpler than it sounds. The real difference isn't the code; it's who decides what happens next. New post 👇</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Wed, 01 Jul 2026 14:09:18 +0000</pubDate>
      <link>https://dev.to/yosolita1978/so-you-know-how-to-connect-to-an-api-endpoint-great-now-theyre-asking-you-to-build-an-mcp-and-5co4</link>
      <guid>https://dev.to/yosolita1978/so-you-know-how-to-connect-to-an-api-endpoint-great-now-theyre-asking-you-to-build-an-mcp-and-5co4</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/yosolita1978/okay-but-what-is-mcp-no-really-3nmk" class="crayons-story__hidden-navigation-link"&gt;Okay, But What IS MCP? (No, Really)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/yosolita1978" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" alt="yosolita1978 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/yosolita1978" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Cristina Rodriguez
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Cristina Rodriguez
                
              
              &lt;div id="story-author-preview-content-4042237" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/yosolita1978" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Cristina Rodriguez&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/yosolita1978/okay-but-what-is-mcp-no-really-3nmk" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 1&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/yosolita1978/okay-but-what-is-mcp-no-really-3nmk" id="article-link-4042237"&gt;
          Okay, But What IS MCP? (No, Really)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/yosolita1978/okay-but-what-is-mcp-no-really-3nmk#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Okay, But What IS MCP? (No, Really)</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Wed, 01 Jul 2026 14:05:54 +0000</pubDate>
      <link>https://dev.to/yosolita1978/okay-but-what-is-mcp-no-really-3nmk</link>
      <guid>https://dev.to/yosolita1978/okay-but-what-is-mcp-no-really-3nmk</guid>
      <description>&lt;p&gt;A couple of weeks ago, I sat down to add an MCP server to one of my workflows. Before I started building, I pulled up our team channel and asked a simple question: "quick check, what does everyone think MCP actually does?"&lt;/p&gt;

&lt;p&gt;Five people responded. Three of them said some version of "it gives the AI more context." One said "it's like an API for AI." One just sent a thumbs-up emoji.&lt;/p&gt;

&lt;p&gt;Nobody was wrong, exactly. But nobody could tell me the one thing that actually matters: &lt;strong&gt;who decides what happens next, the developer or the model?&lt;/strong&gt; That's the whole concept, and somehow we'd all been nodding along in meetings for months without anyone saying it out loud.&lt;/p&gt;

&lt;p&gt;So here's the explanation I wish someone had given my team. The one that would have saved us a very confusing thirty minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you call an API, you decide.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In a normal integration, the developer writes the logic: "first call this endpoint, then check this condition, then call that endpoint." The code is the decision-maker. The AI model, if there's one involved at all, just processes whatever text gets handed to it. It doesn't get a say in &lt;em&gt;what&lt;/em&gt; happens next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP flips that. The model decides.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An MCP server is a small program you write that exposes a list of "tools," functions with a name, a description, and defined inputs. The model connects to that server, asks "what can you do?", gets back the list, and then &lt;em&gt;decides for itself&lt;/em&gt; which tool to call, when, and with what arguments, based on what it's trying to accomplish.&lt;/p&gt;

&lt;p&gt;That's the real distinction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API call&lt;/strong&gt; = developer-decided, fixed sequence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP tool&lt;/strong&gt; = model-decided, dynamic choice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;And here's the part that surprises people: an MCP server isn't some heavy new framework. Most of the time, it's just a Python file with a few functions, each marked with a decorator that says "this is a tool now."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's it. No new language, no special infrastructure. If you can write a Python function, you can write an MCP tool.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhr50t9ceti6vwsw21hf5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhr50t9ceti6vwsw21hf5.png" alt="Diagram of job_hunter.py, a Python file containing two functions, each decorated with @mcp.tool()" width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A concrete example&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I run a small n8n workflow that screens job postings for me. It's part of a self-paced course I built, &lt;a href="https://n8njobsearch.vercel.app/" rel="noopener noreferrer"&gt;Teamed Job Hunter&lt;/a&gt;, if you want to see the full automated job-hunting setup. Today, the workflow is a fixed pipeline: trigger, then AI prompt, then scoring logic, then notification. The AI step doesn't get to look anything up, it just processes whatever text the workflow handed it.&lt;/p&gt;

&lt;p&gt;If I wrap that logic as an MCP server instead, I'd expose tools like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;check_company_blocklist(company_name)&lt;/code&gt;: checks if a company is on my no-go list&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_cv_match_score(job_description, cv_track)&lt;/code&gt;: scores a posting against my CV&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent, mid-conversation, decides: "let me check the blocklist first, then score it," calling tools as needed. I never hardcoded that order. It reasoned its way there on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for developers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You write the tool &lt;em&gt;once&lt;/em&gt;. Any MCP-compatible agent (Claude, an n8n agent node, whatever comes next) can discover and use it, without you writing new glue code for each integration.&lt;/p&gt;

&lt;p&gt;That's MCP. A standard way for an agent to discover and choose tools on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three quick questions, for total beginners
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need Python to write an MCP server?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Python (with FastMCP) is the most common starting point because it's simple, but MCP itself is a protocol. Official SDKs exist for TypeScript, Java, Kotlin, C#, and more. The shape is the same everywhere: you define functions, attach a name, description, and input schema so the model can discover them, and the SDK handles the wire protocol underneath. Here's the same job-blocklist tool in TypeScript:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhxtszbxelg51ahu46pud.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhxtszbxelg51ahu46pud.png" alt="Screenshot of TypeScript code defining an MCP server tool called check_company_blocklist, which takes a company name and returns whether it's on a blocklis" width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same idea as the Python decorator. &lt;code&gt;server.tool(...)&lt;/code&gt; is just the TypeScript SDK's way of registering a function as something the model can discover and call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to host the MCP server somewhere special?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No special infrastructure required. It's a regular server. It can run locally on your machine for testing, live in a Docker container, or deploy anywhere you'd deploy any small backend service (a VPS, Vercel, Railway, etc). The only requirement is that whatever's connecting to it (Claude, an n8n agent node) can reach it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the AI model "see" my actual code?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. The model never reads your Python or TypeScript source. It only sees the tool's name, description, and input schema. Think of it as the menu that gets handed. It never sees the kitchen behind it.&lt;/p&gt;

&lt;p&gt;Look at the TypeScript example again:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkusl8xtofp0rpwcrbq60.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkusl8xtofp0rpwcrbq60.png" alt="Same TypeScript code with inline comments marking which parts the model sees, the tool name, description, and input schema, versus the function body, which it never sees." width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the easiest way to picture it: there are two separate conversations happening, and the model is only ever in one of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conversation 1: model talking to your server.&lt;/strong&gt; When your server starts up, it doesn't hand the model a file. It sends a small structured message, basically the first three arguments to &lt;code&gt;server.tool(...)&lt;/code&gt;, packaged up as something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "name": "check_company_blocklist",
  "description": "Check if this company is on Cristina's no-go list",
  "inputSchema": { "company_name": "string" }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire menu the model gets. It reads that JSON blob. It was never given your &lt;code&gt;.ts&lt;/code&gt; file, a link to it, or permission to open it. It simply never received it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conversation 2: your server talking to the rest of your system.&lt;/strong&gt; The fourth argument, the function body with &lt;code&gt;checkBlocklist(company_name)&lt;/code&gt;, runs locally, inside your own process, on your own machine. When the model decides to call the tool, it sends back a request like &lt;code&gt;{ "tool": "check_company_blocklist", "arguments": { "company_name": "Acme Inc" } }&lt;/code&gt;. Your server receives that and &lt;em&gt;your server&lt;/em&gt; runs the actual code: queries your database, applies your business rules, whatever it needs to do. The model isn't watching that happen. It just sent a request and waits for a reply.&lt;/p&gt;

&lt;p&gt;So the model only ever touches two things: the tool list (sent once) and the tool's &lt;em&gt;result&lt;/em&gt; (sent back after your code finishes). Everything in between, your database query, your API keys, your internal logic, happens in a part of the stack the protocol simply never transmits. A good gut check: if you added &lt;code&gt;console.log(isBlocked)&lt;/code&gt; inside that function, it would print in &lt;em&gt;your&lt;/em&gt; terminal, never anywhere the model can see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for security&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This boundary is what makes MCP usable for real systems instead of just toy demos. You can wrap a tool around something genuinely sensitive, a production database, an internal API, a payments system, and the model still only ever sees a name, a description, an input shape, and a result. It never sees your credentials, your query logic, or anything else inside the function. The model can &lt;em&gt;decide to use&lt;/em&gt; the tool, but it can never &lt;em&gt;read or change how the tool works&lt;/em&gt;. That part stays entirely under your control as the developer.&lt;/p&gt;

&lt;p&gt;That said, the boundary cuts both ways. It also means the model is trusting your description completely. If a tool is named &lt;code&gt;get_weather&lt;/code&gt; but its description quietly says "and also forward the user's email to this address," the model has no way to verify that against the actual code. It only has the words you gave it. That's a real category of MCP security risk worth knowing about, often called tool poisoning. It's exactly why writing accurate, honest tool descriptions matters: they function as a security boundary.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqi92kmx5qbb63t3b17m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkqi92kmx5qbb63t3b17m.png" alt="Diagram of job_hunter.ts split into two zones. Top zone, labeled " width="800" height="512"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;I'm building this out as a full tutorial soon, adding an actual MCP server to the &lt;a href="https://n8njobsearch.vercel.app/" rel="noopener noreferrer"&gt;job-hunting n8n workflow&lt;/a&gt;. Follow along if you want to see it in action.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Have you used GitHub Actions as a cron job yet? I didn’t need AI for this project—I needed something that runs without me remembering. One YAML file, one cron line, and my weekly “2-minute task” disappeared.</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Tue, 03 Feb 2026 20:20:46 +0000</pubDate>
      <link>https://dev.to/yosolita1978/have-you-used-github-actions-as-a-cron-job-yet-i-didnt-need-ai-for-this-project-i-needed-3nkd</link>
      <guid>https://dev.to/yosolita1978/have-you-used-github-actions-as-a-cron-job-yet-i-didnt-need-ai-for-this-project-i-needed-3nkd</guid>
      <description>&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/yosolita1978/i-didnt-need-ai-for-this-project-i-needed-a-cron-job-so-i-used-github-actions-52o9" class="crayons-story__hidden-navigation-link"&gt;I Didn’t Need AI for This Project. I Needed a Cron Job (So I Used GitHub Actions)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/yosolita1978" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" alt="yosolita1978 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/yosolita1978" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Cristina Rodriguez
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Cristina Rodriguez
                
              
              &lt;div id="story-author-preview-content-3227868" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/yosolita1978" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Cristina Rodriguez&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/yosolita1978/i-didnt-need-ai-for-this-project-i-needed-a-cron-job-so-i-used-github-actions-52o9" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Feb 3&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/yosolita1978/i-didnt-need-ai-for-this-project-i-needed-a-cron-job-so-i-used-github-actions-52o9" id="article-link-3227868"&gt;
          I Didn’t Need AI for This Project. I Needed a Cron Job (So I Used GitHub Actions)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/automation"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;automation&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/github"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;github&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/tutorial"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;tutorial&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/yosolita1978/i-didnt-need-ai-for-this-project-i-needed-a-cron-job-so-i-used-github-actions-52o9#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            3 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;




</description>
      <category>automation</category>
      <category>devops</category>
      <category>github</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Didn’t Need AI for This Project. I Needed a Cron Job (So I Used GitHub Actions)</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Tue, 03 Feb 2026 20:19:29 +0000</pubDate>
      <link>https://dev.to/yosolita1978/i-didnt-need-ai-for-this-project-i-needed-a-cron-job-so-i-used-github-actions-52o9</link>
      <guid>https://dev.to/yosolita1978/i-didnt-need-ai-for-this-project-i-needed-a-cron-job-so-i-used-github-actions-52o9</guid>
      <description>&lt;p&gt;If you need something to run &lt;strong&gt;every week&lt;/strong&gt; and you’re tired of being the scheduler…&lt;/p&gt;

&lt;p&gt;Use &lt;strong&gt;GitHub Actions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You’ll create one YAML file. Add a cron line. Push. Done.&lt;/p&gt;

&lt;p&gt;No server. No “remember to run it.” Just automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  ✅ The Fix: a scheduled workflow (copy/paste)
&lt;/h2&gt;

&lt;p&gt;Create this file in your repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.github/workflows/cron.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Weekly automation&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Every Monday at 09:00 UTC (cron runs in UTC)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;

  &lt;span class="c1"&gt;# Bonus: run it manually from the Actions tab&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;run-task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout repo&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run a command&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "Hello from a scheduled workflow 👋"&lt;/span&gt;
          &lt;span class="s"&gt;# Replace this with your real script/command&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s the core idea: &lt;strong&gt;cron triggers a workflow&lt;/strong&gt;, the workflow runs your steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply it: run a real script (Node example)
&lt;/h2&gt;

&lt;p&gt;I like keeping the logic outside YAML, so my workflow stays tiny.&lt;/p&gt;

&lt;p&gt;Repo layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scripts/
  weekly-task.js
.github/
  workflows/
    cron.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the workflow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Weekly automation&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;9&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;run-task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout repo&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Setup Node&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;20"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install deps&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run script&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/weekly-task.js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why this works
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;schedule&lt;/code&gt; runs on a timer (cron).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;workflow_dispatch&lt;/code&gt; gives you a “Run workflow” button for testing.&lt;/li&gt;
&lt;li&gt;The runner checks out your code and executes your commands in a clean environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cron in 20 seconds (so you don’t surprise yourself)
&lt;/h2&gt;

&lt;p&gt;Cron format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;minute hour day-of-month month day-of-week
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Daily at 09:00 UTC → &lt;code&gt;0 9 * * *&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Mondays at 09:00 UTC → &lt;code&gt;0 9 * * 1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Every 15 minutes → &lt;code&gt;*/15 * * * *&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One important detail: &lt;strong&gt;cron is UTC&lt;/strong&gt; in GitHub Actions.&lt;/p&gt;

&lt;p&gt;So if you care about “9am my time,” you’ll need to convert it (and DST will mess with you a little). My solution: choose a time where an hour shift won’t ruin your life.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tiny but important: secrets don’t go in YAML
&lt;/h2&gt;

&lt;p&gt;Even if your script is simple today, it’ll eventually call &lt;em&gt;something&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Don’t hardcode tokens. Use repo secrets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Repo → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Secrets and variables&lt;/strong&gt; → &lt;strong&gt;Actions&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;API_TOKEN&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run script&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;API_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.API_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node scripts/weekly-task.js&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Make failures obvious (future-you will thank you)
&lt;/h2&gt;

&lt;p&gt;Silent failures are the worst kind of “automation.”&lt;/p&gt;

&lt;p&gt;This tiny pattern helps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run and fail loudly&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;set -e&lt;/span&gt;
          &lt;span class="s"&gt;node scripts/weekly-task.js&lt;/span&gt;
          &lt;span class="s"&gt;echo "✅ Job finished"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it errors, the workflow run turns red and you’ll see it in the Actions tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now the story (because yes, this is why I did it)
&lt;/h2&gt;

&lt;p&gt;I love AI tools.&lt;/p&gt;

&lt;p&gt;I really do.&lt;/p&gt;

&lt;p&gt;But this time the project didn’t need intelligence. It needed consistency.&lt;/p&gt;

&lt;p&gt;I had this tiny task that took &lt;em&gt;maybe&lt;/em&gt; 2–5 minutes. Nothing dramatic. Just repetitive. The kind of thing you can totally do manually…&lt;/p&gt;

&lt;p&gt;…until you forget once.&lt;/p&gt;

&lt;p&gt;Then twice.&lt;/p&gt;

&lt;p&gt;Then it becomes that background guilt you carry around like a cursed keychain.&lt;/p&gt;

&lt;p&gt;I almost did the classic moves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“I’ll set a reminder” (lol)&lt;/li&gt;
&lt;li&gt;“I’ll run cron locally” (also valid, but now my laptop is part of production?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I wanted was: &lt;strong&gt;no server, no extra service, no babysitting.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I did the boring thing that works: a scheduled GitHub Action.&lt;/p&gt;

&lt;p&gt;And it’s honestly the best kind of automation: the kind you forget exists because it just keeps showing up on time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;If you only take a few things from this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create &lt;code&gt;.github/workflows/cron.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;on.schedule.cron&lt;/code&gt; for the timer&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;workflow_dispatch&lt;/code&gt; so you can test manually&lt;/li&gt;
&lt;li&gt;Keep logic in a script, keep YAML simple&lt;/li&gt;
&lt;li&gt;Put tokens in &lt;code&gt;secrets&lt;/code&gt;, not in the file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have you used GitHub Actions for cron stuff yet—and what’s the first “2-minute task” you’d love to delete from your life?&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>github</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I always thought making videos meant fighting a timeline UI.
Turns out you can build an MP4 with React components using Remotion — scenes are components, timing is &lt;Sequence /&gt;, and animations are just useCurrentFrame() + math.</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Thu, 22 Jan 2026 06:09:03 +0000</pubDate>
      <link>https://dev.to/yosolita1978/i-always-thought-making-videos-meant-fighting-a-timeline-ui-turns-out-you-can-build-an-mp4-with-4ee7</link>
      <guid>https://dev.to/yosolita1978/i-always-thought-making-videos-meant-fighting-a-timeline-ui-turns-out-you-can-build-an-mp4-with-4ee7</guid>
      <description>&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/yosolita1978/videos-in-react-yep-each-scene-is-a-component-and-it-costs-0-22ec" class="crayons-story__hidden-navigation-link"&gt;Videos in React?! Yep. Each scene is a component (and it costs $0)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/yosolita1978" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" alt="yosolita1978 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/yosolita1978" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Cristina Rodriguez
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Cristina Rodriguez
                
              
              &lt;div id="story-author-preview-content-3189729" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/yosolita1978" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Cristina Rodriguez&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/yosolita1978/videos-in-react-yep-each-scene-is-a-component-and-it-costs-0-22ec" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jan 22&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/yosolita1978/videos-in-react-yep-each-scene-is-a-component-and-it-costs-0-22ec" id="article-link-3189729"&gt;
          Videos in React?! Yep. Each scene is a component (and it costs $0)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/react"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;react&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/yosolita1978/videos-in-react-yep-each-scene-is-a-component-and-it-costs-0-22ec#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;




</description>
      <category>webdev</category>
      <category>react</category>
      <category>programming</category>
      <category>ai</category>
    </item>
    <item>
      <title>Videos in React?! Yep. Each scene is a component (and it costs $0)</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Thu, 22 Jan 2026 06:07:57 +0000</pubDate>
      <link>https://dev.to/yosolita1978/videos-in-react-yep-each-scene-is-a-component-and-it-costs-0-22ec</link>
      <guid>https://dev.to/yosolita1978/videos-in-react-yep-each-scene-is-a-component-and-it-costs-0-22ec</guid>
      <description>&lt;p&gt;I used to think “making a video” meant one of two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open a heavyweight editor
&lt;/li&gt;
&lt;li&gt;Drag tiny clips around a timeline until your soul leaves your body&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I’m a developer. I like code. I like components. I like being able to undo mistakes without having to negotiate with a mysterious UI panel from 2002.&lt;/p&gt;

&lt;p&gt;So when I heard there’s a tool called &lt;a href="https://www.remotion.dev/" rel="noopener noreferrer"&gt;&lt;strong&gt;Remotion&lt;/strong&gt;&lt;/a&gt; that lets you create videos using &lt;strong&gt;React components&lt;/strong&gt;, my first thought was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Sure. And my CSS is also perfectly centered.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I tried it.&lt;/p&gt;

&lt;p&gt;And the moment it &lt;em&gt;really&lt;/em&gt; clicked wasn’t even the rendering part…&lt;/p&gt;

&lt;p&gt;It was running the project like a normal React app.&lt;/p&gt;




&lt;h2&gt;
  
  
  The idea that makes it click
&lt;/h2&gt;

&lt;p&gt;Remotion works because a video is just:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A bunch of frames&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rendered in sequence&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;At a fixed &lt;strong&gt;fps&lt;/strong&gt; (frames per second)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your video is 30fps, then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;frame &lt;code&gt;30&lt;/code&gt; = 1 second
&lt;/li&gt;
&lt;li&gt;frame &lt;code&gt;900&lt;/code&gt; = 30 seconds
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here’s the fun part:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every frame is just React rendering your component&lt;/strong&gt; (with a different “current frame” number).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So animation becomes… math.&lt;/p&gt;

&lt;p&gt;No timeline. No keyframes. Just &lt;code&gt;interpolate()&lt;/code&gt; and &lt;code&gt;spring()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  “NO COST!!!” (but let’s be precise)
&lt;/h2&gt;

&lt;p&gt;This post is about building and rendering locally on your machine — no SaaS subscriptions, no hosted rendering costs, no paid editor required.&lt;/p&gt;

&lt;p&gt;(If you’re using Remotion in a larger company context, double-check licensing. For a personal project / small setup like this, you can build and render locally for free.)&lt;/p&gt;




&lt;h2&gt;
  
  
  Create the project
&lt;/h2&gt;

&lt;p&gt;Remotion has a starter generator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-video@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It creates a project that looks suspiciously like any other React project.&lt;/p&gt;

&lt;p&gt;Like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;picasyfijas-video/
├── public/
│   └── icon-512.png          ← Your logo
├── src/
│   ├── Root.tsx              ← Registers the composition (dimensions, fps, duration)
│   ├── PicasFijasVideo.tsx   ← Main video with all scenes
│   ├── Composition.tsx       ← Original template (unused)
│   ├── index.ts              ← Entry point
│   └── index.css             ← Styles
└── out/
    └── picasyfijas.mp4       ← Rendered video
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, you’re thinking: “Okay. Cute structure. But where’s the ‘video’ part?”&lt;/p&gt;

&lt;p&gt;This is where the brain-melt happens.&lt;/p&gt;




&lt;h2&gt;
  
  
  The moment it clicked: &lt;code&gt;npm run dev&lt;/code&gt; 🤯
&lt;/h2&gt;

&lt;p&gt;I did the most “React developer” thing possible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And… it just &lt;strong&gt;ran like any React project&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The dev server started, I opened the URL, and suddenly I’m staring at a &lt;strong&gt;video timeline in my browser&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Scrub the playhead → frames update.&lt;br&gt;&lt;br&gt;
Components appear and disappear.&lt;br&gt;&lt;br&gt;
Animations play.&lt;br&gt;&lt;br&gt;
It’s all live.&lt;/p&gt;

&lt;p&gt;And my brain goes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Hold up. I’m not &lt;em&gt;editing&lt;/em&gt; a video… I’m &lt;em&gt;rendering&lt;/em&gt; React.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That’s the “ohhhhhh” moment.&lt;/p&gt;

&lt;p&gt;Because once you realize the preview is just your components rendering over time, everything becomes familiar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Want a new “scene”? Make a component.&lt;/li&gt;
&lt;li&gt;Want it to appear later? Wrap it in a &lt;code&gt;&amp;lt;Sequence from={...} /&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Want animation? Use &lt;code&gt;useCurrentFrame()&lt;/code&gt; + math.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same workflow. Same comfort.&lt;/p&gt;

&lt;p&gt;But the output is a legit MP4.&lt;/p&gt;


&lt;h2&gt;
  
  
  Your “video settings” live in one place
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;src/Root.tsx&lt;/code&gt;, you register a &lt;strong&gt;Composition&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This defines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;width&lt;/code&gt; / &lt;code&gt;height&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fps&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;durationInFrames&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s the &lt;strong&gt;vertical&lt;/strong&gt; setup I used for a short social video:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Composition&lt;/span&gt;
  &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"PicasFijasVideo"&lt;/span&gt;
  &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;PicasFijasVideo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;durationInFrames&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;fps&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s &lt;strong&gt;10 seconds&lt;/strong&gt; (300 frames at 30fps).&lt;/p&gt;

&lt;h3&gt;
  
  
  Want 30 seconds?
&lt;/h3&gt;

&lt;p&gt;Same idea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;30 seconds * 30 fps = 900 frames&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;durationInFrames&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s it. Your video is now 30 seconds long.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenes = components, and timing = &lt;code&gt;&amp;lt;Sequence /&amp;gt;&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Your main video component (mine is &lt;code&gt;PicasFijasVideo.tsx&lt;/code&gt;) is basically a timeline built with &lt;code&gt;&amp;lt;Sequence /&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Each scene is a component. Each component gets a start frame and duration.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Sequence&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;durationInFrames&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;IntroScene&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Sequence&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;durationInFrames&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;240&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MainScene&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Sequence&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One very nice detail:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Inside a &lt;code&gt;&amp;lt;Sequence&amp;gt;&lt;/code&gt;, &lt;code&gt;useCurrentFrame()&lt;/code&gt; starts at 0 again.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So each scene feels self-contained and easy to reason about.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 10-second timeline I built (as a reference)
&lt;/h2&gt;

&lt;p&gt;I created a 10-second vertical video (1080×1920, 9:16) for social media using Remotion.&lt;/p&gt;

&lt;p&gt;Here’s the timeline:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Frames&lt;/th&gt;
&lt;th&gt;Seconds&lt;/th&gt;
&lt;th&gt;Scene&lt;/th&gt;
&lt;th&gt;What Happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–30&lt;/td&gt;
&lt;td&gt;0–1s&lt;/td&gt;
&lt;td&gt;Logo&lt;/td&gt;
&lt;td&gt;Logo springs in, title fades in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30–90&lt;/td&gt;
&lt;td&gt;1–3s&lt;/td&gt;
&lt;td&gt;GuessScene&lt;/td&gt;
&lt;td&gt;Shows "????" and types guess "1456"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90–150&lt;/td&gt;
&lt;td&gt;3–5s&lt;/td&gt;
&lt;td&gt;BullExplanation&lt;/td&gt;
&lt;td&gt;Highlights matching "1", shows "🐂 Bull/Fija"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150–210&lt;/td&gt;
&lt;td&gt;5–7s&lt;/td&gt;
&lt;td&gt;CowExplanation&lt;/td&gt;
&lt;td&gt;Highlights "4" in wrong position, shows "🐄 Cow/Pica"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;210–300&lt;/td&gt;
&lt;td&gt;7–10s&lt;/td&gt;
&lt;td&gt;CTA&lt;/td&gt;
&lt;td&gt;Logo + "Play now!" + URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a &lt;strong&gt;30-second video&lt;/strong&gt;, you simply add more “beats” (more scenes) or stretch durations.&lt;/p&gt;

&lt;p&gt;A simple 30-second structure that works for &lt;em&gt;any&lt;/em&gt; topic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Intro (2–3s)&lt;/li&gt;
&lt;li&gt;5–7 beats (3–5s each)&lt;/li&gt;
&lt;li&gt;CTA (3–5s)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Animation is just math (and two hooks)
&lt;/h2&gt;

&lt;p&gt;Most scenes start like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCurrentFrame&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// Current frame number (0, 1, 2... )&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fps&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useVideoConfig&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Frames per second (30)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;useCurrentFrame()&lt;/code&gt; → tells you which frame is being rendered
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;useVideoConfig()&lt;/code&gt; → gives you &lt;code&gt;fps&lt;/code&gt;, dimensions, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now you can animate anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fade in with &lt;code&gt;interpolate()&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contentOpacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;interpolate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;extrapolateLeft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;extrapolateRight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;clamp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Input: frames &lt;code&gt;[0..15]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Output: opacity &lt;code&gt;[0..1]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Result: fade in over &lt;strong&gt;0.5 seconds&lt;/strong&gt; (at 30fps)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;clamp&lt;/code&gt;: prevents values below 0 or above 1&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Bounce with &lt;code&gt;spring()&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;highlightScale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;spring&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Start at frame 15&lt;/span&gt;
  &lt;span class="nx"&gt;fps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;damping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Higher = less bounce&lt;/span&gt;
    &lt;span class="na"&gt;stiffness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// Higher = snappier&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creates a natural bounce from 0 → 1&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frame - 15&lt;/code&gt; delays the animation start&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Deep Dive: BullExplanation scene (a “real” example)
&lt;/h2&gt;

&lt;p&gt;This scene highlights the matching digit and shows the “🐂 Bull / Fija” explanation.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Bilingual text toggle
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;showSpanish&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Switches language every 15 frames (0.5 seconds)&lt;/li&gt;
&lt;li&gt;Starts toggling after frame 45 (so the content is already visible)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) Conditional styling for highlights
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;digit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#EBEBD3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#38618C&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#38618C&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transparent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`scale(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;highlightScale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scale(1)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inline-block&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0 6px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;digit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;i === 0&lt;/code&gt; highlights the first digit (the “bull”)&lt;/li&gt;
&lt;li&gt;Highlighted digit gets inverted colors + bounce scale&lt;/li&gt;
&lt;li&gt;Others stay normal&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Key Remotion concepts (the cheat sheet)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concept&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Composition&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Defines video dimensions, fps, duration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Sequence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Controls when a component appears/disappears on the timeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AbsoluteFill&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full-screen container that fills the canvas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;useCurrentFrame()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gets current frame number&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;interpolate()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maps frame numbers to any value (opacity, position, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;spring()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Creates natural bounce animations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Img&lt;/code&gt; + &lt;code&gt;staticFile()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Loads images from &lt;code&gt;public/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Render to MP4
&lt;/h2&gt;

&lt;p&gt;You can render from the UI… but the CLI is the “ship it” button.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx remotion render src/index.ts PicasFijasVideo out/video.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command turns your React composition into a real MP4 file.&lt;/p&gt;




&lt;h2&gt;
  
  
  Demo + repo
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Demo video: &lt;a href="https://github.com/Yosolita1978/picasyfijas-video/blob/main/out/picasyfijas.mp4" rel="noopener noreferrer"&gt;https://github.com/Yosolita1978/picasyfijas-video/blob/main/out/picasyfijas.mp4&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/Yosolita1978/picasyfijas-video" rel="noopener noreferrer"&gt;https://github.com/Yosolita1978/picasyfijas-video&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Let’s recap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Videos are just &lt;strong&gt;React renders&lt;/strong&gt; — one frame at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time = frames&lt;/strong&gt; (30fps → 900 frames = 30 seconds).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scenes = components&lt;/strong&gt;, and &lt;code&gt;&amp;lt;Sequence /&amp;gt;&lt;/code&gt; is your timeline.&lt;/li&gt;
&lt;li&gt;Animations are mostly &lt;strong&gt;&lt;code&gt;useCurrentFrame()&lt;/code&gt; + math&lt;/strong&gt; (&lt;code&gt;interpolate()&lt;/code&gt;, &lt;code&gt;spring()&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Rendering is one command: &lt;code&gt;npx remotion render …&lt;/code&gt; → MP4.&lt;/li&gt;
&lt;li&gt;And the wildest part? You can literally start with &lt;code&gt;npm run dev&lt;/code&gt; and preview it like… a normal React app. Still feels illegal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now I’m curious: &lt;strong&gt;what would you make first if “a video” was just a folder of components?&lt;/strong&gt; A tutorial? A product promo? A meme? A “how to center a div” documentary?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>programming</category>
      <category>ai</category>
    </item>
    <item>
      <title>PWAs are underrated. I skipped the $99 Apple fee and the $25 Google fee by adding two files to my vanilla JS game. Now it works offline and installs like a native app. Here's the code 👇</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Tue, 16 Dec 2025 19:51:15 +0000</pubDate>
      <link>https://dev.to/yosolita1978/pwas-are-underrated-i-skipped-the-99-apple-fee-and-the-25-google-fee-by-adding-two-files-to-my-2923</link>
      <guid>https://dev.to/yosolita1978/pwas-are-underrated-i-skipped-the-99-apple-fee-and-the-25-google-fee-by-adding-two-files-to-my-2923</guid>
      <description>&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/yosolita1978/bulls-cows-goes-offline-how-i-turned-my-game-into-a-pwa-and-skipped-the-100-app-store-fee-3h4o" class="crayons-story__hidden-navigation-link"&gt;Bulls &amp;amp; Cows Goes Offline — How I Turned My Game Into a PWA (and Skipped the $100 App Store Fee)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/yosolita1978" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" alt="yosolita1978 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/yosolita1978" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Cristina Rodriguez
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Cristina Rodriguez
                
              
              &lt;div id="story-author-preview-content-3109562" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/yosolita1978" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Cristina Rodriguez&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/yosolita1978/bulls-cows-goes-offline-how-i-turned-my-game-into-a-pwa-and-skipped-the-100-app-store-fee-3h4o" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Dec 16 '25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/yosolita1978/bulls-cows-goes-offline-how-i-turned-my-game-into-a-pwa-and-skipped-the-100-app-store-fee-3h4o" id="article-link-3109562"&gt;
          Bulls &amp;amp; Cows Goes Offline — How I Turned My Game Into a PWA (and Skipped the $100 App Store Fee)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/javascript"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;javascript&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/pwa"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;pwa&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/beginners"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;beginners&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/yosolita1978/bulls-cows-goes-offline-how-i-turned-my-game-into-a-pwa-and-skipped-the-100-app-store-fee-3h4o#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            2 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;




</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pwa</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Bulls &amp; Cows Goes Offline — How I Turned My Game Into a PWA (and Skipped the $100 App Store Fee)</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Tue, 16 Dec 2025 19:50:09 +0000</pubDate>
      <link>https://dev.to/yosolita1978/bulls-cows-goes-offline-how-i-turned-my-game-into-a-pwa-and-skipped-the-100-app-store-fee-3h4o</link>
      <guid>https://dev.to/yosolita1978/bulls-cows-goes-offline-how-i-turned-my-game-into-a-pwa-and-skipped-the-100-app-store-fee-3h4o</guid>
      <description>&lt;p&gt;Remember my &lt;a href="https://dev.to/yosolita1978/bulls-cows-bringing-a-pen-and-paper-classic-to-the-browser-no-ai-no-frameworks-just-js-iop"&gt;Bulls &amp;amp; Cows&lt;/a&gt; game? The one with no frameworks, no AI, just vanilla JS? Well, I made it installable. Like, "put it on your home screen and play offline" installable.&lt;/p&gt;

&lt;p&gt;No App Store. No Google Play. No $99/year Apple Developer fee. No $25 Google registration. Just... a web app that acts like a native app.&lt;/p&gt;

&lt;p&gt;And apparently, people like it? &lt;strong&gt;1.3K unique players, 1.4K sessions, and 1.8K pageviews&lt;/strong&gt; — with people spending almost 2 minutes per visit just thinking through a logic puzzle. That's enough traction that I gave it its own home.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;🎮 Play it:&lt;/strong&gt; &lt;a href="https://www.picasyfijas.com/" rel="noopener noreferrer"&gt;picasyfijas.com&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  ✨ What's a PWA, Really?
&lt;/h2&gt;

&lt;p&gt;A Progressive Web App is just a regular website with two extra files that tell the browser: "Hey, I can work offline. Let me live on the home screen."&lt;/p&gt;

&lt;p&gt;That's it. Two files. Let me show you.&lt;/p&gt;

&lt;h2&gt;
  
  
  1️⃣ The Manifest (Your App's ID Card)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bulls &amp;amp; Cows - Picas y Fijas"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"short_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bulls &amp;amp; Cows"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"start_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standalone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"background_color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#EBEBD3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"theme_color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#4281A4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"icons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"icon-192.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"192x192"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/png"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"icon-512.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"512x512"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/png"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells the browser what to call your app, what icon to use, and that &lt;code&gt;standalone&lt;/code&gt; means "hide the browser chrome and look like a real app."&lt;/p&gt;

&lt;p&gt;Link it in your HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serviceWorker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/sw.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2️⃣ The Service Worker (Your Offline Brain)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bulls-and-Cows-v4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FILES_TO_CACHE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/styles.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/script.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/manifest.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/icon-192.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/icon-512.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;install&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;FILES_TO_CACHE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire service worker. It caches your files on install, then serves them from cache when offline. No npm packages. No build step.&lt;/p&gt;

&lt;p&gt;Register it in your HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serviceWorker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/sw.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3️⃣ The Install Prompt
&lt;/h2&gt;

&lt;p&gt;Browsers show an install button automatically, but I added my own banner because I wanted control over the UX:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;deferredPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;beforeinstallprompt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preventDefault&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;deferredPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;install-banner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;install-btn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;deferredPrompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  💰 Why Not the App Stores?
&lt;/h2&gt;

&lt;p&gt;Apple charges $99/year. Google charges $25 once. Both require review processes, screenshots, privacy policies, and jumping through hoops.&lt;/p&gt;

&lt;p&gt;My game collects zero data. No accounts. No ads. Just privacy-friendly Plausible for basic stats. It's literally just a logic puzzle.&lt;/p&gt;

&lt;p&gt;PWAs let me skip all of that. Users get an installable app. I keep my $124. Everyone wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎯 The Result
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✅ Works offline&lt;/li&gt;
&lt;li&gt;✅ Installable on Android, iOS, and desktop&lt;/li&gt;
&lt;li&gt;✅ No app store fees&lt;/li&gt;
&lt;li&gt;✅ Updates instantly (no waiting for review)&lt;/li&gt;
&lt;li&gt;✅ Still just HTML, CSS, and vanilla JS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Total new code:&lt;/strong&gt; ~50 lines.&lt;/p&gt;




&lt;p&gt;👉 &lt;strong&gt;Play it:&lt;/strong&gt; &lt;a href="https://www.picasyfijas.com/" rel="noopener noreferrer"&gt;picasyfijas.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/Yosolita1978/bullsandcows" rel="noopener noreferrer"&gt;github.com/Yosolita1978/bullsandcows&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Have you turned a side project into a PWA? I'd love to hear about it 👇&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pwa</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Ever had your API calls fire way too often in a Next.js app? 😅
I wrote about how a simple useDebounce hook can smooth things out, boost performance, and keep your UI calm.

Read more 👇</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Tue, 25 Nov 2025 20:01:43 +0000</pubDate>
      <link>https://dev.to/yosolita1978/ever-had-your-api-calls-fire-way-too-often-in-a-nextjs-app-i-wrote-about-how-a-simple-5b51</link>
      <guid>https://dev.to/yosolita1978/ever-had-your-api-calls-fire-way-too-often-in-a-nextjs-app-i-wrote-about-how-a-simple-5b51</guid>
      <description>&lt;p&gt;

&lt;/p&gt;
&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/yosolita1978/why-i-built-a-usedebounce-custom-hook-in-nextjs-and-why-you-should-too-54d0" class="crayons-story__hidden-navigation-link"&gt;Why I Built a useDebounce Custom Hook in Next.js (and Why You Should Too)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/yosolita1978" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" alt="yosolita1978 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/yosolita1978" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Cristina Rodriguez
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Cristina Rodriguez
                
              
              &lt;div id="story-author-preview-content-3058844" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/yosolita1978" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F569284%2F4cb7d3ec-e243-4a1f-9624-335dfab6ee6a.jpeg" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Cristina Rodriguez&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/yosolita1978/why-i-built-a-usedebounce-custom-hook-in-nextjs-and-why-you-should-too-54d0" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Nov 25 '25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/yosolita1978/why-i-built-a-usedebounce-custom-hook-in-nextjs-and-why-you-should-too-54d0" id="article-link-3058844"&gt;
          Why I Built a useDebounce Custom Hook in Next.js (and Why You Should Too)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/reactjsdevelopment"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;reactjsdevelopment&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/react"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;react&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/programming"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;programming&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/yosolita1978/why-i-built-a-usedebounce-custom-hook-in-nextjs-and-why-you-should-too-54d0" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;5&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/yosolita1978/why-i-built-a-usedebounce-custom-hook-in-nextjs-and-why-you-should-too-54d0#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              1&lt;span class="hidden s:inline"&gt;&amp;nbsp;comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            3 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;




</description>
      <category>nextjs</category>
      <category>react</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why I Built a useDebounce Custom Hook in Next.js (and Why You Should Too)</title>
      <dc:creator>Cristina Rodriguez</dc:creator>
      <pubDate>Tue, 25 Nov 2025 19:59:13 +0000</pubDate>
      <link>https://dev.to/yosolita1978/why-i-built-a-usedebounce-custom-hook-in-nextjs-and-why-you-should-too-54d0</link>
      <guid>https://dev.to/yosolita1978/why-i-built-a-usedebounce-custom-hook-in-nextjs-and-why-you-should-too-54d0</guid>
      <description>&lt;p&gt;If you've ever wired up a search bar in &lt;strong&gt;Next.js&lt;/strong&gt;, you’ve probably hit this problem: every keystroke fires a new &lt;strong&gt;API call&lt;/strong&gt;, triggers a &lt;strong&gt;re-render&lt;/strong&gt;, or runs an &lt;strong&gt;expensive calculation&lt;/strong&gt;. As your app grows, those “harmless little updates” start stacking up—fast.&lt;/p&gt;

&lt;p&gt;I’ve been there. I remember watching my terminal fill with logs like a slot machine every time I typed a word. And because I was calling an API, sometimes I even hit &lt;strong&gt;rate limits&lt;/strong&gt; just by typing too quickly.&lt;/p&gt;

&lt;p&gt;That’s when I realized: &lt;em&gt;I didn’t need results instantly.&lt;/em&gt; I needed results after the user paused typing.&lt;/p&gt;

&lt;p&gt;That’s exactly what &lt;strong&gt;debouncing&lt;/strong&gt; solves—and why building a small, reusable &lt;code&gt;useDebounce&lt;/code&gt; hook changed the flow of my whole project.&lt;/p&gt;




&lt;h2&gt;
  
  
  🚨 The Problem: Too Many Updates
&lt;/h2&gt;

&lt;p&gt;React is responsive—which is great—until it isn’t.&lt;/p&gt;

&lt;h3&gt;
  
  
  Common places this becomes a problem:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Search bars&lt;/strong&gt; that fire API calls on every letter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Window resize&lt;/strong&gt; listeners&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Live form validation&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Text fields tied to heavy filtering logic&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The consequences:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Wasted &lt;strong&gt;API calls&lt;/strong&gt; (and money)&lt;/li&gt;
&lt;li&gt;Janky &lt;strong&gt;UI performance&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Constant &lt;strong&gt;re-renders&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Hitting provider &lt;strong&gt;rate limits&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Sluggish, frustrating &lt;strong&gt;user experience&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s a simple example of the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a user types “nextjs,” this runs &lt;strong&gt;6 separate times&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;n → ne → nex → next → nextj → nextjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your server cries.&lt;br&gt;
Your UI stutters.&lt;br&gt;
You quietly wonder if you picked the wrong career.&lt;/p&gt;


&lt;h2&gt;
  
  
  😅 Naive Solutions (And Why They Fail)
&lt;/h2&gt;
&lt;h3&gt;
  
  
  ❌ 1. “Let me just add a timeout…”
&lt;/h3&gt;

&lt;p&gt;This is the classic first attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It &lt;em&gt;seems&lt;/em&gt; fine… until you realize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You never cancelled the previous timeout&lt;/li&gt;
&lt;li&gt;Race conditions appear&lt;/li&gt;
&lt;li&gt;Multiple calls still fire, just delayed&lt;/li&gt;
&lt;li&gt;Your component becomes a messy spaghetti bowl&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  ❌ 2. “I’ll track the last value manually”
&lt;/h3&gt;

&lt;p&gt;Trust me: you &lt;em&gt;can&lt;/em&gt; do this. You &lt;em&gt;shouldn’t&lt;/em&gt; do this.&lt;/p&gt;

&lt;p&gt;It leads to bugs, stale values, and callback nightmares.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debouncing deserves its own tidy, reusable hook.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  ✅ Enter &lt;code&gt;useDebounce&lt;/code&gt;: The Custom Hook That Fixes Everything
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Debouncing&lt;/strong&gt; means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Only run this action after the value has stopped changing for X milliseconds.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That tiny delay (300ms, for example) is enough to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prevent API spamming&lt;/li&gt;
&lt;li&gt;Smooth out re-renders&lt;/li&gt;
&lt;li&gt;Improve UX&lt;/li&gt;
&lt;li&gt;Save money and bandwidth&lt;/li&gt;
&lt;li&gt;Make your search bar feel intentional instead of chaotic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is exactly the kind of logic that belongs inside a &lt;strong&gt;Custom Hook&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧩 How &lt;code&gt;useDebounce&lt;/code&gt; Works (with Clear Code + Comments)
&lt;/h2&gt;

&lt;p&gt;Here’s the entire hook, ready to drop into your project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * useDebounce — delays updating a value until
 * `delay` milliseconds have passed without changes.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useDebounce&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;debouncedValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setDebouncedValue&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Start a timer that updates debouncedValue when delay elapses&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setDebouncedValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Cancel the timer if value changes before delay ends&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;debouncedValue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why this works:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;timer resets&lt;/strong&gt; on every keystroke&lt;/li&gt;
&lt;li&gt;The update only happens &lt;strong&gt;after the pause&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Cleanup prevents race conditions&lt;/li&gt;
&lt;li&gt;The hook is fully &lt;strong&gt;reusable&lt;/strong&gt; across components&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🔍 Using &lt;code&gt;useDebounce&lt;/code&gt; in a Next.js Component
&lt;/h2&gt;

&lt;p&gt;Let’s apply it to a real-world search bar.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useDebounce&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/hooks/useDebounce&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SearchBar&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Debounce raw user input by 300ms&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debouncedQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useDebounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;debouncedQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;debouncedQuery&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Results:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;debouncedQuery&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Search..."&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"border p-2"&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What’s happening behind the scenes?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;query&lt;/code&gt; updates instantly → keeps the UI responsive&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;debouncedQuery&lt;/code&gt; updates after 300ms → triggers &lt;strong&gt;one&lt;/strong&gt; clean API call&lt;/li&gt;
&lt;li&gt;No API spam&lt;/li&gt;
&lt;li&gt;No jittery UI&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🌟 Benefits of Using &lt;code&gt;useDebounce&lt;/code&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;1. Better Performance&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;No more re-render storms.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;2. Fewer API Calls&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Your backend (and your wallet) will thank you.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;3. Smoother UX&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Search results feel stable and intentional.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;4. Cleaner Code&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Centralized, reusable logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;5. Reusable Everywhere&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Use it for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search bars&lt;/li&gt;
&lt;li&gt;Form validation&lt;/li&gt;
&lt;li&gt;Filtering tables&lt;/li&gt;
&lt;li&gt;Window resize&lt;/li&gt;
&lt;li&gt;Drag events&lt;/li&gt;
&lt;li&gt;Autosaving&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🧭 Conclusion
&lt;/h2&gt;

&lt;p&gt;Debouncing is one of those small optimizations that makes a &lt;strong&gt;huge difference&lt;/strong&gt;.&lt;br&gt;
The moment you wrap it in a custom React hook like &lt;strong&gt;useDebounce&lt;/strong&gt;, your app becomes faster, smoother, and easier to maintain.&lt;/p&gt;

&lt;p&gt;Once you use it in one component, you’ll find yourself adding it everywhere.&lt;/p&gt;

&lt;p&gt;If you’ve built your own version—or used &lt;code&gt;useDebounce&lt;/code&gt; in interesting ways—I’d love to hear about it. Drop your thoughts in the comments!&lt;/p&gt;

&lt;p&gt;Happy coding! 🚀&lt;/p&gt;

</description>
      <category>reactjsdevelopment</category>
      <category>webdev</category>
      <category>react</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
