Skip to content

Using XState Actors to Model Async Workflows Safely

Using XState Actors to Model Async Workflows Safely

This article was written over 18 months ago and may contain information that is out of date. Some content may be relevant but please refer to the relevant official documentation or available resources for the latest information.

In my previous post I discussed the challenges of writing async workflows in React that correctly deal with all possible edge cases. Even for a simple case of a client with two dependencies with no error handling, we ended up with this code:

const useClient = (user) => {
  const [client, setClient] = useState(null);

  useEffect(() => {
    let cancelled;
    (async () => {
      const clientAuthToken = await fetchClientToken(user);
      if (cancelled) return;

      const connection = await createWebsocketConnection();
      if (cancelled) {
        connection.close();
        return;
      }

      const client = await createClient(connection, clientAuthToken);
      if (cancelled) {
        client.disconnect();
        return;
      }

      setClient(client);
    })();

    return () => {
      cancelled = true;
    };
  }, [user]);

  useEffect(() => {
    return () => {
      client.disconnect();
    };
  }, [client]);

  return client;
};

This tangle of highly imperative code functions, but it will prove hard to read and change in the future. What we need is a way to express the stateful nature of the various pieces of this workflow and how they interact with each other in a way in which we can easily see if we've missed something or make changes in the future. This is where state machines and the actor model can come in handy.

State machines? Actors?

These are programming patterns that you may or may not have heard of before. I will explain them in a brief and simplified way but you should know that there is a great deal of theoretical and practical background in this area that we will be leveraging even though we won't go over it explicitly.

  1. A state machine is an entity consisting of state and a series of rules to be followed to determine the next state from a combination of its previous state and external events it receives. Even though you might rarely think about them, state machines are everywhere. For example, a Promise is a state machine going from pending to resolved state when it receives a value from the asynchronous computation it is wrapping.
  2. The actor model is a computing architecture that models asynchronous workflows as the interplay of self-contained units called actors. These units communicate with each other by sending and receiving events, they encapsulate state and they exist in a hierarchical relationship, where parent actors spawn child actors, thus linking their lifecycles.

It's common to combine both patterns so that a single entity is both an actor and a state machine, so that child actors are spawned and messages are sent based on which state the entity is in. I'll be using XState, a Javascript library which allows us to create actors and state machines in an easy declarative style. This won't be a complete introductory tutorial to XState, though. So if you're unfamiliar with the tool and need context for the syntax I'll be using, head to their website to read through the docs.

Setting the stage

The first step is to break down our workflow into the distinct states it can be in. Not every step in a process is a state. Rather, states represent moments in the process where the workflow is waiting for something to happen, whether that is user input or the completion of some external process. In our case we can break our workflow down coarsely into three states:

  1. When the workflow is first created, we can immediately start creating the connection, and fetching the auth token. But, we have to wait until those are finished before creating the client. We'll call this state "preparing".
  2. Then, we've started the process of creating the client, but we can't use it until the client creation returns it to us. We'll call this state "creatingClient".
  3. Finally, everything is ready, and the client can be used. The machine is waiting only for the exit signal so it can release its resources and destroy itself. We'll call this state "clientReady".

This can be represented visually like so (all visualizations produced with Stately):

Basic state machine

And in code like so

export const clientFactory = createMachine({
  id: "clientFactory",
  initial: "preparing",
  states: {
    preparing: {
      on: {
        "preparations.complete": {
          target: "creatingClient",
        },
      },
    },
    creatingClient: {
      on: {
        "client.ready": {
          target: "clientReady",
        },
      },
    },
    clientReady: {},
  },
});

However, this is a bit overly simplistic. When we're in our "preparing" state there are actually two separate and independent processes happening, and both of them must complete before we can start creating the client. Fortunately, this is easily represented with parallel child state nodes. Think of parallel state nodes like Promise.all: they advance independently but the parent that invoked them gets notified when they all finish. In XState, "finishing" is defined as reaching a state marked "final", like so

export const clientFactory = createMachine({
  id: "clientFactory",
  initial: "preparing",
  states: {
    preparing: {
      // Parallel state nodes are a good way to model two independent
      // workflows that should happen at the same time
      type: "parallel",
      states: {
        // The token child node
        token: {
          initial: "fetching",
          states: {
            fetching: {
              on: {
                "token.ready": {
                  target: "done",
                },
              },
            },
            done: {
              type: "final",
            },
          },
        },
        // The connection child node
        connection: {
          initial: "connecting",
          states: {
            connecting: {
              on: {
                "connection.ready": {
                  target: "done",
                },
              },
            },
            done: {
              type: "final",
            },
          },
        },
      },
      // The "onDone" transition on parallel state nodes gets called when all child
      // nodes have entered their "final" state. It's a great way to wait until
      // various workflows have completed before moving to the next step!
      onDone: {
        target: "creatingClient",
      },
    },
    creatingClient: {
      on: {
        "client.ready": {
          target: "clientReady",
        },
      },
    },
    clientReady: {},
  },
});

Leaving us with the final shape of our state chart:

Using XState actors to model async workflows safely - Complete state machine

Casting call

So far, we only have a single actor: the root actor implicitly created by declaring our state machine. To unlock the real advantages of using actors we need to model all of our disposable resources as actors. We could write them as full state machines using XState but instead let's take advantage of a short and sweet way of defining actors that interact with non-XState code: functions with callbacks. Here is what our connection actor might look like, creating and disposing of a WebSocket:

// Demonstrated here is the simplest and most versatile form of actor: a function that
// takes a callback that sends events to the parent actor, and returns a function that
// will be called when it is stopped.
const createConnectionActor = () => (send) => {
  const connection = new WebSocket("wss://example.com");

  connection.onopen = () =>
    // We send an event to the parent that contains the ready connection
    send({ type: "connection.ready", data: connection });

  // Actors are automatically stopped when their parent stops so simple actors are a great
  // way to manage resources that need to be disposed of. The function returned by an
  // actor will be called when it receives the stop signal.
  return () => {
    connection.close();
  };
};

And here is one for the client, which demonstrates the use of promises inside a callback actor. You can spawn promises as actors directly but they provide no mechanism for responding to events, cleaning up after themselves, or sending any events other than "done" and "error", so they are a poor choice in most cases. It's better to invoke your promise-creating function inside a callback actor, and use the Promise methods like .then() to control async responses.

// We can have the actor creation function take arguments, which we will populate
// when we spawn it
const createClientActor => (token, connection) => (send) => {
  const clientPromise = createClient(token, connection);
  clientPromise.then((client) =>
    send({ type: "client.ready", data: client })
  );

  return () => {
    // A good way to make sure the result of an async function is
    // always cleaned up is by invoking cleanup through .then()
    // If this executes before the promise is resolved, it will cleanup
    // on resolution, whereas if it executes after it's resolved, it will
    // clean up immediately
    clientPromise.then((client) => {
      client.disconnect();
    });
  };
};

Actors are spawned with the spawn action creator from XState, but we also need to save the reference to the running actor somewhere, so spawn is usually combined with assign to create an actor, and save it into the parent's context.

// We put this as the machine options. Machine options can be customised when the
// machine is interpreted, which gives us a way to use values from e.g. React context
// to define our actions, although this is not demonstrated here
const clientFactoryOptions = {
  spawnConnection: assign({
    connectionRef: () => spawn(createConnectionActor()),
  }),
  spawnClient: assign({
    // The assign action creator lets us use the machine context when defining the
    // state to be assigned, this way actors can inherit parent state
    clientRef: (context) =>
      spawn(createClientActor(context.token, context.connection)),
  }),
};

And then it becomes an easy task to trigger these actions when certain states are entered:

export const clientFactory = createMachine({
  id: "clientFactory",
  initial: "preparing",
  states: {
    preparing: {
      type: "parallel",
      states: {
        token: {
          initial: "fetching",
          states: {
            fetching: {
              // Because there's no resource to manage once it's done, we
              // can simply invoke a promise here. Invoked services are like
              // actors, but they're automatically spawned when the state node
              // is entered, and destroyed when it is exited,
              invoke: {
                src: "fetchToken",
                // Invoking a promise provides us with a handy "onDone" transition
                // that triggers when the promise resolves. To handle rejections,
                // we would similarly implement "onError"
                onDone: {
                  // These "save" actions will save the result to the machine
                  // context. They're simple assigners, but you can see them in
                  // the full code example linked at the end.
                  actions: "saveToken",
                  target: "done",
                },
              },
            },
            done: {
              type: "final",
            },
          },
        },
        // The connection child node
        connection: {
          initial: "connecting",
          states: {
            connecting: {
              // We want our connection actor to stick around, because by design,
              // the actor destroys the connection when it exits, so we store
              // it in state by using a "spawn" action
              entry: "spawnConnection",
              on: {
                // Since we're dealing with a persistent actor, we don't get an
                // automatic "onDone" transition. Instead, we rely on the actor
                // to send us an event.
                "connection.ready": {
                  actions: "saveConnection",
                  target: "done",
                },
              },
            },
            done: {
              type: "final",
            },
          },
        },
      },
      onDone: {
        target: "creatingClient",
      },
    },
    creatingClient: {
      // The same pattern as the connection actor. We spawn a  persistent actor
      // that takes care of creating and destroying the client.
      // entry: "spawnClient",
      on: {
        "client.ready": {
          actions: "saveClient",
          target: "clientReady",
        },
      },
    },
    // Even though this node can't be exited, it is not "final". A final node would
    // cause the machine to stop operating, which would stop the child actors!
    clientReady: {},
  },
});

Putting on the performance

XState provides hooks that simplify the process of using state machines in React, making this the equivalent to our async hook at the start:

const useClient = (user) => {
  const [state] = useMachine(clientFactory, clientFactoryOptions);

  if (state.matches("clientReady")) return state.context.client;
  return null;
};

Of course, combined with the machine definition, the action definitions and the actor code are hardly less code, or even simpler code. The advantage of breaking a workflow down like this include:

  1. Each part can be tested independently. You can verify that the machine follows the logic set out without invoking the actors, and you can verify that the actors clean up after themselves without running the whole machine.
  2. The parts can be shuffled around, and added to without having to rewrite them. We could easily add an extra step between connecting and creating the client, or introduce error handling and error states.
  3. We can read and visualize every state and transition of the workflow to make sure we've accounted for all of them. This is a particular improvement over long async/await chains where every await implicitly creates a new state and two transitions — success and error — and the precise placement of catch blocks can drastically change the shape of the state chart.

You won't need to break out these patterns very often in an application. Maybe once or twice, or maybe never. After all, many applications never have to worry about complex workflows and disposable resources. However, having these ideas in your back pocket can get you out of some jams, particularly if you're already using state machines to model UI behaviour — something you should definitely consider doing if you're not already.

A complete code example with everything discussed above using Typescript, and with mock actors and services, that actually run in the visualizer, can be found here.

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.

You might also like

Remix's evolution to a Vite plugin cover image

Remix's evolution to a Vite plugin

The Remix / React Router news is a beautiful illustration of software evolution. You start with a clear idea of what you’re building, but it’s impossible to anticipate exactly what it will become. If you haven’t heard the news yet, Ryan Florence announced at React Conf 2024 that Remix will be a Vite plugin for React Router. It’s a sort of surprising announcement but it makes a lot of sense if you’ve been following along as Remix the project has evolved. In this post we’ll recap the evolution of the project and then dig into what this change means for the future of Remix in the Server Components era of React. 🗓️ October 2021 > Remix is open sourced In the beginning, Remix cost money. In October 2021 they received some seed funding and the project was open sourced. Remix was an exciting new framework for server-rendering React websites that included API’s for loading data on the server and submitting data to the server with forms. It started a trend in the front-end framework space that a lot of other popular frameworks have modeled after or taken inspiration from. 🗓️ March 2022 - September 2022 > Remixing React Router At some point they realized that the API’s that they had created for Remix were a more natural fit in React Router. In this post Ryan details the plans to move these Remix API’s into React Router. Several months later, React Router 6.4 is released with the action, loader, and other Remix API’s baked in. > "We've always thought of Remix as "just a compiler and server for React Router" After this release, Remix is now mostly as they described - a compiler and server for React Router. 🗓️ October 2023 - February 2024 > Remix gets Vite support If you’re not familiar with Vite, it’s an extremely popular tool for front-end development that handles things like development servers and code bundling/compiling. A lot of the modern front-end frameworks are built on top of it. Remix announced support for Vite as an alternative option to their existing compiler and server. Since Remix is now mostly “just a compiler and server for React Router” - what’s left of Remix now that Vite can handle those things? 🗓️ May 2024 > Remix is React Router The Remix team releases a post detailing the announcement that Ryan made at React Conf. Looking back through the evolution of the project this doesn’t seem like an out of the blue, crazy announcement. Merging Remix API’s into React Router and then replacing the compiler and server with Vite didn’t leave much for Remix to do as a standalone project. 🐐 React Router - the past, present, and future of React React Router is one of the oldest, and most used packages in the React ecosystem. > Since Remix has always been effectively "React Router: The Framework", we wanted to create a bridge for all these React Router projects to be able to upgrade to Remix. With Remix being baked into React Router and now supporting SPA mode, legacy React Router applications have an easier path for migrating to the next generation of React. https://x.com/ryanflorence/status/1791488550162370709 Remix vs React 19 If you’ve taken a look at React 19 at all, it turns out it now handles a lot of the problems that Remix set out to solve in the first place. Things like Server Components, Server Actions, and Form Actions provide the same sort of programming model that Remix popularized. https://x.com/ryanflorence/status/1791484663883829258 Planned Changes for Remix and React Router Just recently, a post was published giving the exact details of what the changes to Remix and React Router will look like. tl;dr For React Router * React Router v6 to v7 will be a non-breaking upgrade * The Vite plugin from Remix is coming to React Router in v7 * The Vite plugin simply makes existing React Router features more convenient to use, but it isn't required to use React Router v7 * v7 will support both React 18 and React 19 For Remix * What would have been Remix v3 is React Router v7 * Remix v2 to React Router v7 will be a non-breaking upgrade * Remix is coming back better than ever in a future release with an incremental adoption strategy enabled by these changes For Both * React Router v7 comes with new features not in Remix or React Router today: RSC, server actions, static pre-rendering, and enhanced Type Safety across the board The show goes on I love how this project has evolved and I tip my hat to the Remix/React Router team. React Router feels like a cozy blanket in an ecosystem that is going through some major changes which feels uncertain and a little scary at times. The Remix / React Router story is still being written and I’m excited to see where it goes. I’m looking forward to seeing the work they are doing to support React Server Components. A lot of people are pretty excited about RSC’s and there is a lot of room for unique and interesting implementations. Long live React Router 🙌....

Why is My React Reducer Called Twice and What the Heck is a Pure Function? cover image

Why is My React Reducer Called Twice and What the Heck is a Pure Function?

Why is My React Reducer Called Twice and What the Heck is a Pure Function? In a recent project, we encountered an interesting issue: our React reducer was dispatching twice, producing incorrect values, such as incrementing a number in increments of two. We hopped on a pairing session and started debugging. Eventually, we got to the root of the problem and learned the importance of pure functions in functional programming. This article will explain why our reducer was being dispatched twice, what pure functions are, and how React's strict mode helped us identify a bug in our code. The Issue We noticed that our useReducer hook was causing the reducer function to be called twice for every action dispatched. Initially, we were confused about this behavior and thought it might be a bug in React. Additionally, we had one of the dispatches inside a useEffect, which caused it to be called twice due to React strict mode, effectively firing the reducer four times and further complicating our debugging process. However, we knew that React's strict mode caused useEffect to be called twice, so it didn't take very long to realize that the issue was not with React but with how we had implemented our reducer function. React Strict Mode React's strict mode is a tool for highlighting potential problems in an application. It intentionally double-invokes specific lifecycle methods and hooks (like useReducer and useEffect) to help developers identify side effects. This behavior exposed our issue, as we had reducers that were not pure functions. What is a Pure Function? A pure function is a function that: - Is deterministic: Given the same input, always returns the same output. - Does Not Have Side Effects: Does not alter any external state or have observable interactions with the outside world. In the context of a reducer, this means the function should not: - Modify its arguments - Perform any I/O operations (like network requests or logging) - Generate random numbers - Depend on any external state Pure functions are predictable and testable. They help prevent bugs and make code easier to reason about. In the context of React, pure functions are essential for reducers because they ensure that the state transitions are predictable and consistent. The Root Cause: Impure Reducers Our reducers were not pure functions. They were altering external state and had side effects, which caused inconsistent behavior when React's strict mode double-invoked them. This led to unexpected results and made debugging more difficult. The Solution: Make Reducers Pure To resolve this issue, we refactored our reducers to ensure they were pure functions. Here's an extended example of how we transformed an impure reducer into a pure one in a more complex scenario involving a task management application. Let's start with the initial state and action types: ` And here's the impure reducer similar to what we had initially: ` This reducer is impure because it directly modifies the state object, which is a side effect. To make it pure, we must create a new state object for every action and return it without modifying the original state. Here's the refactored pure reducer: ` Key Changes: - Direct State Modification: In the impure reducer, the state is directly modified (e.g., state.tasks.push(action.payload)). This causes side effects and violates the principles of pure functions. - Side Effects: The impure reducer included side effects such as logging and direct state changes. The pure reducer eliminates these side effects, ensuring consistent and predictable behavior. I've created an interactive example to demonstrate the difference between impure and pure reducers in a React application. Despite the RESET_TASKS action being implemented similarly in both reducers, you'll notice that the impure reducer does not reset the tasks correctly. This problem happens because the impure reducer directly modifies the state, leading to unexpected behavior. Check out the embedded StackBlitz example below: Conclusion Our experience with the reducer dispatching twice was a valuable lesson in the importance of pure functions in React. Thanks to React's strict mode, we identified and fixed impure reducers, leading to more predictable and maintainable code. If you encounter similar issues, ensure your reducers are pure functions and leverage React strict mode to catch potential problems early in development. By embracing functional programming principles, you can write cleaner, more reliable code that is easier to debug and maintain....

Using Astro on framework.dev cover image

Using Astro on framework.dev

Have you heard of Astro? It's an exciting, if still experimental, static site generation framework that allows you to author components using your choice of framework and completely control if and when javascript is shipped to the client. If you would like to learn more, I wrote an introductory blog post about it. Considering how new and experimental Astro is, you might be wondering what it's like to actually try to build a website with it. Well, you're in luck because we chose to build react.framework.dev in Astro, and I'm here to tell you how it went. Some background When I was first presented the pitch for the framework.dev project, it had the following characteristics: 1. It was going to be primarily a reference site, allowing users to browse static data. 2. It should be low-cost, fast and accessible. 3. Because it was a small internal project, we were pretty free to go out on a limb on our technology choices, especially if it let us learn something new. At the time, I had recently heard exciting things about this new static site generator called "Astro" from a few developers on Twitter, and from cursory research, it seemed perfect for what we had planned. Most of the site was going to be category pages for browsing, with search existing as a bonus feature for advanced users. Astro would allow us to create the whole site in React but hydrate it only on the search pages, giving us the best of the static and dynamic worlds. This plan didn't quite survive contact with the enemy (see the "ugly" section below) but it was good enough to get the green light. We also picked vanilla-extract as a styling solution because we wanted to be able to use the same styles in both React and Astro and needed said styles to be easily themeable for the different site variants. With its wide array of plugins for different bundlers, it seemed like an extremely safe solution. Being able to leverage Typescript type-checking and intellisense to make sure theme variables were always referenced correctly was certainly helpful. But as you'll see, the journey was much less smooth than expected. The Good Astro did exactly what it had promised. Defining what static pages would be generated based on data, and what sections of those pages would be hydrated on the client, was extremely straightforward. The experience was very similar to using NextJS, with the same system of file system paths defining the routing structure of the site, augmented with a getStaticPaths function to define the generation of dynamic routes. However, Astro is easier to use in many ways due to its more focused feature set: - Very streamlined and focused documentation. - No potential confusion between static generation and server-rendering. - All code in Astro frontmatter is run at build-time only, so it's much easier to know where it's safe to write or import code that shouldn't be leaked to the client bundle. - No need to use special components for things like links, which greatly simplifies writing and testing components. Our Storybook setup didn't have to know about Astro at all, and in fact is running exactly the same code but bundling it with its own webpack-based builder. Choosing what code should be executed client-side is so easy that describing it is somewhat anticlimactic: ` However, this is a feature that is simply not present in any other framework, and it gives a site very predictable performance characteristics: each page loads only as much Javascript as has been marked as needing to be loaded. This means that each page can be profiled and optimized in isolation, and increases in complexity in other pages will not affect it. For example, we have kept the site's homepage entirely static, so even when it shares code with dynamic areas like search, the volume of that code doesn't impact load times. The Bad Although we were pleasantly surprised by how many things worked flawlessly despite Astro being relatively new, we were still plagued by a number of small issues: - A lot of things didn't work in a monorepo. We had to revert to installing all npm libraries in the root package rather than using the full power of workspaces, as we had trouble with hoisting and path resolution. Furthermore, we had to create local shims for any component we wanted to be a hydration root, as Astro didn't handle monorepo imports being roots. - We were hit multiple times with a hard-to-trace issue that prevented hydration from working correctly with a variety of errors mostly relating to node modules being left in the client bundle. The only way to fix this was to clear the Snowpack cache and build the site for production before trying to start the dev server. You can imagine how long it took to figure out this slightly bizarre workaround. - Astro integration with Typescript and Prettier was pretty shaky, so the experience of editing Astro components was a bit of a throwback to the days before mature Javascript tooling and editor integrations. I'm very thankful that we had always intended to write almost all of our components in React rather than Astro's native format. We also hit a larger hurdle that contributed to the above problems' remaining issues for the lifetime of the project: Astro moved from Snowpack to Vite in version 0.21, but despite vanilla-extract having a Vite plugin, we were unable to get CSS working with the new compiler. It's uncertain whether this is an issue with Astro's Vite compiler, or whether it's down to vanilla-extract's Vite plugin not having been updated for compatibility with Vite's new (and still experimental) SSR mode that Astro uses under the hood. Whatever the reason, what we had thought was a very flexible styling solution left us locked in version 0.20 with all its issues. The lesson to be learnt here is probably that when dealing with new and untested frameworks it's wise to stick to the approaches recommended by their authors, because there's no guarantees of backwards compatibility for third-party extensions. The Ugly As alluded to in the introduction, our plans for framework.dev evolved in ways that made the benefits of Astro less clear. As the proposed design for the site evolved, the search experience was foregrounded and eventually became the core of every page other than the homepage. Instead of a static catalogue with an option to search, we found ourselves building a search experience where browsing was just a selection of pre-populated search terms. This means that, in almost all pages, almost all of the page is hydrated, because it is an update-results-as-you-type search box. In these conditions, where most pages are fully interactive and share almost all of their code, it's arguable that a client-side-rendering SPA like you'd get with NextJS or Gatsby would be of equal or even superior performance. Only slightly more code would have to be downloaded for the first view and subsequent navigation would actually require less markup to be fetched from the server. The flip side of choosing the right tool for the job is that the job can often change during development as product ideas are refined, and feedback is incorporated. However, even though replacing Astro with NextJS would be fairly simple since the bulk of the code is in plain React components, we decided against it. Even Astro's worst case is still quite acceptable, and maybe in the future, we will add features to the site which will be able to truly take advantage of the islands-of-interactivity hydration model. Closing thoughts Astro's documentation does not lie. It is a very flexible and easy to use framework with unique options to improve performance that is also still firmly in an experimental stage of its development. You should not use it in large production projects yet. The ground is likely to shift under you as it did with us in the Snowpack-to-Vite move. The team behind Astro is now targeting a 1.0 release which will hopefully mean greater guarantees of backwards compatibility and a lack of major bugs. It will be interesting to see what features end up making it in, and whether developer tools like auto-formatting, linting and type-checking are going to also be finished and supported going forward. Without those quality of life improvements, Astro has still been an exciting tool to test out, which has made me think differently about the possibilities of static generation. With them, it might become the only SSG framework you will need....

Vercel BotID: The Invisible Bot Protection You Needed cover image

Vercel BotID: The Invisible Bot Protection You Needed

Nowadays, bots do not act like “bots”. They can execute JavaScript, solve CAPTCHAs, and navigate as real users. Traditional defenses often fail to meet expectations or frustrate genuine users. That’s why Vercel created BotID, an invisible CAPTCHA that has real-time protections against sophisticated bots that help you protect your critical endpoints. In this blog post, we will explore why you should care about this new tool, how to set it up, its use cases, and some key considerations to take into account. We will be using Next.js for our examples, but please note that this tool is not tied to this framework alone; the only requirement is that your app is deployed and running on Vercel. Why Should You Care? Think about these scenarios: - Checkout flows are overwhelmed by scalpers - Signup forms inundated with fake registrations - API endpoints draining resources with malicious requests They all impact you and your users in a negative way. For example, when bots flood your checkout page, real customers are unable to complete their purchases, resulting in your business losing money and damaging customer trust. Fake signups clutter the app, slowing things down and making user data unreliable. When someone deliberately overloads your app’s API, it can crash or become unusable, making users angry and creating a significant issue for you, the owner. BotID automatically detects and filters bots attempting to perform any of the above actions without interfering with real users. How does it work? A lightweight first-party script quickly gathers a high set of browser & environment signals (this takes ~30ms, really fast so no worry about performance issues), packages them into an opaque token, and sends that token with protected requests via the rewritten challenge/proxy path + header; Vercel’s edge scores it, attaches a verdict, and checkBotId() function simply reads that verdict so your code can allow or block. We will see how this is implemented in a second! But first, let’s get started. Getting Started in Minutes 1. Install the SDK: ` 1. Configure redirects Wrap your next.config.ts with BotID’s helper. This sets up the right rewrites so BotID can do its job (and not get blocked by ad blockers, extensions, etc.): ` 2. Integrate the client on public-facing pages (where BotID runs checks): Declare which routes are protected so BotID can attach special headers when a real user triggers those routes. We need to create instrumentation-client.ts (place it in the root of your application or inside a src folder) and initialize BotID once: ` instrumentation-client.ts runs before the app hydrates, so it’s a perfect place for a global setup! If we have an inferior Next.js version than 15.3, then we would need to use a different approach. We need to render the React component inside the pages or layouts you want to protect, specifying the protected routes: ` 3. Verify requests on your server or API: ` - NOTE: checkBotId() will fail if the route wasn’t listed on the client, because the client is what attaches the special headers that let the edge classify the request! You’re all set - your routes are now protected! In development, checkBotId() function will always return isBot = false so you can build without friction. To disable this, you can override the options for development: ` What happens on a failed check? In our example above, if the check failed, we return a 403, but it is mostly up to you what to do in this case; the most common approaches for this scenario are: - Hard block with a 403 for obviously automated traffic (just what we did in the example above) - Soft fail (generic error/“try again”) when you want to be cautious. - Step-up (require login, email verification, or other business logic). Remember, although rare, false positives can occur, so it’s up to you to determine how you want to balance your fail strategy between security, UX, telemetry, and attacker behavior. checkBotId() So far, we have seen how to use the property isBot from checkBotId(), but there are a few more properties that you can leverage from it. There are: isHuman (boolean): true when BotID classifies the request as a real human session (i.e., a clear “pass”). BotID is designed to return an unambiguous yes/no, so you can gate actions easily. isBot (boolean): We already saw this one. It will be true when the request is classified as automated traffic. isVerifiedBot (boolean): Here comes a less obvious property. Vercel maintains and continuously updates a comprehensive directory of known legitimate bots from across the internet. This directory is regularly updated to include new legitimate services as they emerge. This could be helpful for allowlists or custom logic per bot. We will see an example in a sec. verifiedBotName? (string): The name for the specific verified bot (e.g., “claude-user”). verifiedBotCategory? (string): The type of the verified bot (e.g., “webhook”, “advertising”, “ai_assistant”). bypassed (boolean): it is true if the request skipped BotID check due to a configured Firewall bypass (custom or system). You could use this flag to avoid taking bot-based actions when you’ve explicitly bypassed protection. Handling Verified Bots - NOTE: Handling verified bots is available in botid@1.5.0 and above. It might be the case that you don’t want to block some verified bots because they are not causing damage to you or your users, as it can sometimes be the case for AI-related bots that fetch your site to give information to a user. We can use the properties related to verified bots from checkBotId() to handle these scenarios: ` Choosing your BotID mode When leveraging BotID, you can choose between 2 modes: - Basic Mode: Instant session-based protection, available for all Vercel plans. - Deep Analysis Mode: Enhanced Kasada-powered detection, only available for Pro and Enterprise plan users. Using this mode, you will leverage a more advanced detection and will block the hardest to catch bots To specify the mode you want, you must do so in both the client and the server. This is important because if either of the two does not match, the verification will fail! ` Conclusion Stop chasing bots - let BotID handle them for you! Bots are and will get smarter and more sophisticated. BotID gives you a simple way to push back without slowing your customers down. It is simple to install, customize, and use. Stronger protection equals fewer headaches. Add BotID, ship with confidence, and let the bots trample into a wall without knowing what’s going on....

Let's innovate together!

We're ready to be your trusted technical partners in your digital innovation journey.

Whether it's modernization or custom software solutions, our team of experts can guide you through best practices and how to build scalable, performant software that lasts.

Prefer email? hi@thisdot.co