> ## Documentation Index
> Fetch the complete documentation index at: https://docs.claimr.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Integrate Claimr into a JavaScript framework

> Learn how the Claimr widget loader behaves inside single-page apps, and the rules every React, Next.js, Vue, or Nuxt integration must follow.

The Claimr widget works in any JavaScript framework. There is no npm package to install: you load one script, `claimr.min.js`, and it renders the widget inside an element you choose. This page explains how that script behaves inside a single-page app (SPA), and lists the rules that keep an integration stable across route changes, re-renders, and server-side rendering.

Read this page first, then follow the guide for your framework:

<CardGroup cols={3}>
  <Card title="React" icon="react" href="/frameworks/react">
    Vite, Create React App, React Router.
  </Card>

  <Card title="Next.js" icon="n" href="/frameworks/nextjs">
    App Router and Pages Router.
  </Card>

  <Card title="Vue and Nuxt" icon="vuejs" href="/frameworks/vue-and-nuxt">
    Vue 3 with Vite, and Nuxt 3 or 4.
  </Card>
</CardGroup>

<Tip>
  Building with an AI coding agent such as Claude Code, Codex, or Cursor? See [Use Claimr docs with AI coding agents](/frameworks/ai-coding-agents) for a ready-made rules file you can drop into your project.
</Tip>

## Before you start

Collect these values from the Claimr admin panel. Open your campaign, click **Publish**, then click **Get embed code**.

| Value | Where it goes | Secret? |
| - | - | - |
| Organization ID | `data-organization` | No. Safe to ship to the browser. |
| Campaign ID | `data-campaign` | No. Safe to ship to the browser. |
| Container ID | `data-container`. Any element ID you choose, for example `claimr-widget`. | No |
| API token | `Authorization` header when you [generate user tokens](/api-guide/user-token) on your server. Only needed if your users sign in through your app. | **Yes.** Never send it to the browser. |

## How the loader works

When `claimr.min.js` runs, it does the following:

1. Finds the script element with `id="claimr-script"` and reads every `data-*` attribute from it. The ID is fixed: the loader looks it up by that exact name.
2. Creates the SDK object at `window.claimr` and posts a `claimr::ready` message to the page window.
3. Looks for the element whose ID matches `data-container`. If it exists, the loader appends the widget `<iframe>` to it. It also appends a full-screen popup layer (`#claimr-contest-popup`, `position: fixed`, `z-index: 1000`) to `<body>`.
4. Watches `<body>` with a `MutationObserver`:
   * When the container element **appears**, the loader attaches the widget to it.
   * When the container element **disappears**, the loader removes the widget iframe and the popup layer.
5. When the widget iframes finish loading, the loader sets `window.claimr.is_claimr_ready` to `true` and posts a `widget::ready` message to the page window. Any `login()` calls made earlier run at this point.
6. If `data-autoresize` is set, the loader writes the container's `height` as an inline style every time the widget content changes size.

```mermaid theme={"dark"}
sequenceDiagram
    participant App as Your app
    participant Loader as claimr.min.js
    participant Widget as Widget iframe
    App->>Loader: Append <script id="claimr-script"> once
    Loader->>App: window.claimr + "claimr::ready"
    App->>App: Render <div id="claimr-widget">
    Loader->>Widget: MutationObserver sees container, appends iframe
    Widget-->>Loader: iframe loaded
    Loader->>App: is_claimr_ready = true + "widget::ready"
    App->>Loader: claimr.set_language(), open_quest(), ...
    App->>App: Route change removes container
    Loader->>Widget: Detach iframe and popup
```

The key consequence for SPAs: **the script is loaded once, and the widget follows the container.** You show and hide the widget by rendering or removing the container element, exactly like any other component. You never re-inject the script to show the widget on a new route.

## Integration rules

Every framework guide in this section follows these rules. If you write your own integration, follow them too.

<Steps>
  <Step title="Load the script once, from the app root">
    Inject the script from your root component, root layout, or app plugin. Do not inject it from the component that renders the widget, and do not inject it on every route. Guard the injection with `document.getElementById('claimr-script')` so that React Strict Mode, hot module reload, and double mounts stay idempotent.
  </Step>

  <Step title="Give the script the ID claimr-script">
    The loader reads its configuration from `document.getElementById('claimr-script')`. Any other ID breaks the integration. Only one Claimr script can exist on a page.
  </Step>

  <Step title="Render the container as an empty element with a stable ID">
    Render a single empty `<div>` whose `id` matches `data-container`. Do not render children inside it and do not bind an inline `height` style to it: the loader owns its content and, with autoresize, its height. Style it with a class instead.
  </Step>

  <Step title="Let the loader attach and detach the widget">
    To hide the widget, remove the container (conditional rendering, route change). To show it again, render the container again. Do not call `destroy()` when a component unmounts: `destroy()` also disconnects the observer and clears the stored wallet session, so the widget will not come back on the next mount.
  </Step>

  <Step title="Read window.claimr at call time">
    Always call `window.claimr?.method()` when you need it. Do not store the object in a variable, module constant, or state: reloading the script replaces it with a new object.
  </Step>

  <Step title="Assign callbacks once, right after the script loads">
    Callbacks such as `on_user_info` are plain properties, so each one has a single owner. Assign them in the same place that loads the script, inside the script's `load` handler, and fan the data out through your app's state (React context, a Vue `reactive` object, Pinia, Nuxt `useState`). Do not assign them from individual components.
  </Step>

  <Step title="Wait for widget::ready before calling widget methods">
    Most methods send a message into the widget iframe. If the iframe has not loaded yet, the message is lost. See [Method readiness](#method-readiness).
  </Step>

  <Step title="Run Claimr code in the browser only">
    `window` and `document` do not exist during server-side rendering. Load the script from an effect, a mounted hook, or a client-only plugin. Rendering the empty container on the server is fine.
  </Step>

  <Step title="Keep the API token on the server">
    Generate [user tokens](/api-guide/user-token) in a server route or server component, then pass only the resulting user token to the browser.
  </Step>
</Steps>

## Method readiness

The loader keeps a few values and replays them when the widget loads. Everything else is sent straight to the iframe and is dropped if the widget is not ready.

| Method | Safe before `widget::ready`? |
| - | - |
| `set_user_token(token)` | Yes. The token is stored and sent when the widget loads. |
| `connect_wallet(...)` | Yes. The wallet is stored and sent when the campaign loads. |
| `open_quest(quest_id)` | Yes. The quest is stored and opened when the campaign loads. |
| `login(in_popup)` | Yes. The call is queued and runs when the widget is ready. |
| `logout()` | Yes for local state. The widget itself is only notified if it is loaded. |
| `set_language`, `set_theme`, `complete_task`, `select_wallet`, `open_profile_popup`, `platform_login` | **No.** Call these after `widget::ready`. |

To wait for readiness, check `window.claimr?.is_claimr_ready`, and if it is `false`, listen for the next `widget::ready` message. The `widget::ready` and `claimr::ready` messages are posted by the loader to your own window, so filter on `event.source === window`:

```ts theme={"dark"}
function whenWidgetReady(): Promise<void> {
  return new Promise((resolve) => {
    if (window.claimr?.is_claimr_ready) return resolve();
    const onMessage = (event: MessageEvent) => {
      if (event.source !== window || event.data?.event !== 'widget::ready') return;
      window.removeEventListener('message', onMessage);
      resolve();
    };
    window.addEventListener('message', onMessage);
  });
}
```

<Note>
  `widget::ready` only fires while a container is mounted. On a page without a container, the promise above stays pending until the user navigates to a page that renders one.
</Note>

## The shared loader module

Every framework guide uses the same framework-agnostic module, `claimr.ts`. It loads the script once, assigns callbacks on every fresh instance, exposes readiness helpers, and declares TypeScript types for `window.claimr`. Copy it into your project as `src/lib/claimr.ts` (or `lib/claimr.ts` in Nuxt).

```ts lib/claimr.ts expandable theme={"dark"}
export const CLAIMR_SCRIPT_ID = 'claimr-script';
export const CLAIMR_SCRIPT_SRC = 'https://widgets.claimr.io/claimr.min.js';

export interface ClaimrConfig {
  organization: string;
  campaign: string;
  /** ID of the element the widget renders into. */
  container: string;
  /** Resize the container to fit the widget content. Omit or pass false to disable. */
  autoresize?: boolean;
  language?: string;
  theme?: string;
  /** `dapp` when your app supplies the wallet, `telegram` inside a Telegram Mini App. */
  platform?: string;
  /** Pre-generated user token, when you already know the signed-in user at load time. */
  userToken?: string;
  showTags?: string;
  hideTags?: string;
  groups?: string;
  ref?: string;
  debug?: boolean;
}

export type ClaimrUser = { id?: string; [key: string]: unknown };
export type ClaimrCampaignInfo = Record<string, unknown>;

export type ClaimrWalletRequest =
  | { op: 'connect'; namespaces: string[] }
  | { op: 'sign_message'; address: string; message: string }
  | {
      op: 'send_transaction';
      chain_id: string;
      address: string;
      request: unknown;
      contract: string;
      method: string;
      args: unknown[];
      abi: unknown[];
      fee?: unknown;
    }
  | { op: 'disconnect' };

export interface Claimr {
  // State
  user: ClaimrUser | null;
  campaign_info: ClaimrCampaignInfo | null;
  is_claimr_ready: boolean;

  // Methods
  complete_task(task_id: string): void;
  connect_wallet(
    address: string,
    signature: string,
    message: string,
    chain?: string | number,
    no_reset?: boolean,
  ): void;
  destroy(): void;
  get_user_info(campaign_id: string, public_key: string, account: string, platform: string): Promise<unknown>;
  login(in_popup?: boolean): void;
  logout(): void;
  open_profile_popup(): void;
  open_quest(quest_id: string): void;
  platform_login(platform: string): void;
  select_wallet(address: string): void;
  set_language(language: string): void;
  set_theme(theme: string): void;
  set_user_token(token: string): void;

  // Callbacks: assign your own function to handle an event
  on_user_info: (user: ClaimrUser) => void;
  on_campaign_info: (info: ClaimrCampaignInfo) => void;
  on_campaign_loading: () => void;
  on_campaign_revealed: () => void;
  on_campaign_error: (error: string) => void;
  on_logout: () => void;
  on_analytics_event: (event: Record<string, unknown>) => void;
  on_leaderboards_info: (entries: unknown[]) => void;
  on_contest_open: () => void;
  on_leaderboard_open: () => void;
  on_market_open: () => void;
  on_custom_login: () => void;
  on_wallet_request?: (request: ClaimrWalletRequest) => Promise<object> | object;
}

declare global {
  interface Window {
    claimr?: Claimr;
  }
}

type Setup = (claimr: Claimr) => void;

let loading: Promise<Claimr> | null = null;
let setupFn: Setup | undefined;

/** The live SDK object, or undefined on the server or before the script has run. */
export function getClaimr(): Claimr | undefined {
  return typeof window === 'undefined' ? undefined : window.claimr;
}

function toAttributes(config: ClaimrConfig): Record<string, string | undefined> {
  return {
    'data-organization': config.organization,
    'data-campaign': config.campaign,
    'data-container': config.container,
    // The loader treats any non-empty value as "on", including "false", so omit it to disable.
    'data-autoresize': config.autoresize === false ? undefined : 'true',
    'data-language': config.language,
    'data-theme': config.theme,
    'data-platform': config.platform,
    'data-user-token': config.userToken,
    'data-show-tags': config.showTags,
    'data-hide-tags': config.hideTags,
    'data-groups': config.groups,
    'data-ref': config.ref,
    'data-debug': config.debug ? 'true' : undefined,
  };
}

/**
 * Loads claimr.min.js once per page session. Safe to call many times.
 * `setup` runs on every fresh SDK instance: assign your callbacks there.
 */
export function loadClaimr(config: ClaimrConfig, setup?: Setup): Promise<Claimr> {
  if (typeof window === 'undefined') {
    return Promise.reject(new Error('Claimr can only be loaded in the browser'));
  }
  if (setup) setupFn = setup;
  if (loading) return loading;

  loading = new Promise<Claimr>((resolve, reject) => {
    const done = () => {
      const claimr = window.claimr;
      if (!claimr) return reject(new Error('claimr.min.js loaded but window.claimr is missing'));
      setupFn?.(claimr);
      resolve(claimr);
    };

    const existing = document.getElementById(CLAIMR_SCRIPT_ID) as HTMLScriptElement | null;
    if (existing) {
      // Already on the page, for example after hot module reload.
      if (window.claimr) return done();
      existing.addEventListener('load', done, { once: true });
      existing.addEventListener('error', () => reject(new Error('Failed to load claimr.min.js')), { once: true });
      return;
    }

    const script = document.createElement('script');
    script.id = CLAIMR_SCRIPT_ID;
    script.src = CLAIMR_SCRIPT_SRC;
    script.async = true;
    for (const [name, value] of Object.entries(toAttributes(config))) {
      if (value) script.setAttribute(name, value);
    }
    script.addEventListener('load', done, { once: true });
    script.addEventListener(
      'error',
      () => {
        loading = null;
        script.remove();
        reject(new Error('Failed to load claimr.min.js'));
      },
      { once: true },
    );
    document.body.appendChild(script);
  });

  return loading;
}

/**
 * Replaces the running widget with a new configuration, for example another campaign.
 * The stored wallet session is cleared, so call connect_wallet or set_user_token again afterwards.
 */
export function reloadClaimr(config: ClaimrConfig): Promise<Claimr> {
  getClaimr()?.destroy();
  document.getElementById(CLAIMR_SCRIPT_ID)?.remove();
  loading = null;
  return loadClaimr(config);
}

/** Resolves when the widget iframe is loaded and accepts commands. */
export function whenWidgetReady(): Promise<Claimr> {
  return new Promise((resolve) => {
    const current = getClaimr();
    if (current?.is_claimr_ready) return resolve(current);
    const onMessage = (event: MessageEvent) => {
      if (event.source !== window || event.data?.event !== 'widget::ready') return;
      window.removeEventListener('message', onMessage);
      resolve(window.claimr!);
    };
    window.addEventListener('message', onMessage);
  });
}

/** Runs `fn` once the widget is ready. Use it for set_language, complete_task, and similar methods. */
export async function withWidget(fn: (claimr: Claimr) => void): Promise<void> {
  fn(await whenWidgetReady());
}
```

## Configuration and environment variables

The organization and campaign IDs are public values, so it is fine to expose them through your bundler's public environment variables. The API token is secret and must only be read on the server.

| Framework | Public (browser) | Server only |
| - | - | - |
| Vite (React, Vue) | `VITE_CLAIMR_ORGANIZATION`, `VITE_CLAIMR_CAMPAIGN` | Your backend's own environment |
| Next.js | `NEXT_PUBLIC_CLAIMR_ORGANIZATION`, `NEXT_PUBLIC_CLAIMR_CAMPAIGN` | `CLAIMR_API_TOKEN`, `CLAIMR_PLATFORM` |
| Nuxt | `NUXT_PUBLIC_CLAIMR_ORGANIZATION`, `NUXT_PUBLIC_CLAIMR_CAMPAIGN` | `NUXT_CLAIMR_API_TOKEN`, `NUXT_CLAIMR_PLATFORM` |

Script attributes are read once, when the script runs. Changing them later has no effect. To change settings at runtime, use the SDK method instead:

| To change | Use |
| - | - |
| Language | `set_language(code)` |
| Theme | `set_theme(name)`, or `data-theme-key` to follow a `localStorage` key |
| Signed-in user | `set_user_token(token)`, and `logout()` when the user signs out |
| Wallet | `connect_wallet(...)` or `select_wallet(address)` |
| Campaign, organization, tags, groups, platform | `reloadClaimr(newConfig)` from the shared module |

See [Widget attributes](/widget/widget-attributes) for every `data-*` attribute and the [SDK guide](/sdk-guide) for every method and callback.

## Identify your users

Pick one of these sign-in models. They are configured per campaign in **Settings** > **Sign-in options**.

| Model | When to use it | What your app does |
| - | - | - |
| Widget sign-in | Users sign in inside the widget with X, Discord, email, and so on. | Nothing. Optionally call `login()` from your own button. |
| User token | Users already have an account in your app. | Generate a token on your server with `GET https://prod.claimr.io/api/v1/token`, then pass it with `data-user-token` or `set_user_token()`. Call `logout()` when the user signs out of your app. See [User token](/api-guide/user-token). |
| Wallet (dApp) | Your app already connects a wallet (wagmi, RainbowKit, Reown, and so on). | Set `platform: 'dapp'` and implement `on_wallet_request`. See [Integrate Claimr widget into your dApp](/how-to/integrate-claimr-widget-into-dapp). |
| Telegram | The app runs as a Telegram Mini App. | Set `platform: 'telegram'`. See [Telegram Mini App](/how-to/integrating-claimr-widget-into-telegram-mini-app). |

The user token endpoint returns `{ "success": true, "data": { "token": "..." } }`. The token does not expire, so you can store it with the user record instead of requesting it on every page load.

## Common mistakes

| Symptom | Cause | Fix |
| - | - | - |
| Nothing renders, no errors | The script tag's ID is not `claimr-script`. | Set `id="claimr-script"`. |
| Nothing renders on one route | The container ID does not match `data-container`, or the container is not in the DOM. | Use the same constant for both. |
| Two widgets, or the widget flickers on navigation | The script is injected from the widget component, so it reloads on every mount. | Load it once from the app root. Only render the container in the widget component. |
| Widget does not come back after navigating away and back | The component calls `destroy()` on unmount. | Remove the `destroy()` call. The loader detaches and re-attaches on its own. |
| Callbacks stop firing | The script was reloaded and the callbacks were assigned to the old `window.claimr`. | Assign callbacks in the `setup` function passed to `loadClaimr`. |
| `set_language` or `complete_task` does nothing | Called before the widget loaded. | Wrap the call in `withWidget(...)`. |
| `ReferenceError: window is not defined` | Claimr code ran during server-side rendering. | Load from an effect, `onMounted`, or a `.client` plugin. |
| Page cannot scroll after navigating away from an open quest | The quest popup sets `overflow: hidden` on `<body>`. | Reset `document.body.style.overflow` when the widget component unmounts, as the framework guides do. |
| Autoresize cannot be turned off | `data-autoresize="false"` still enables it. | Omit the attribute. |
| Widget height fights your layout | The container has a framework-bound inline `height`. | Style the container with a class and let autoresize set the height. |
