> ## 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 Vue or Nuxt app

> Add the Claimr widget to a Vue 3 app built with Vite, or to a Nuxt 3 or 4 app, with a plugin, a widget component, a composable, and server-side user tokens.

This guide covers two setups:

* [Vue 3 with Vite](#vue-3-with-vite): a client-rendered app, usually with Vue Router.
* [Nuxt 3 or 4](#nuxt): server-rendered, with a client-only plugin and a Nitro server route for user tokens.

Both use the same pattern: a plugin loads the script once and turns SDK callbacks into reactive state, a `ClaimrWidget` component renders the container, and a `useClaimr()` composable gives components access to state and methods.

<Info>
  Read [How the loader works](/frameworks/overview#how-the-loader-works) for the reasoning behind this layout. In short: the script loads once for the whole app, and the widget attaches to whichever `ClaimrWidget` is currently mounted.
</Info>

## Vue 3 with Vite

| File | Purpose |
| - | - |
| `src/lib/claimr.ts` | Framework-agnostic loader, readiness helpers, and TypeScript types. |
| `src/plugins/claimr.ts` | Vue plugin: loads the script and provides reactive state. |
| `src/composables/useClaimr.ts` | Composable for Claimr state and SDK methods. |
| `src/components/ClaimrWidget.vue` | Renders the container the widget attaches to. |

<Steps>
  <Step title="Add environment variables">
    ```bash .env theme={"dark"}
    VITE_CLAIMR_ORGANIZATION=your-organization-id
    VITE_CLAIMR_CAMPAIGN=your-campaign-id
    ```
  </Step>

  <Step title="Add the shared loader module">
    Create `src/lib/claimr.ts` with the contents of [the shared loader module](/frameworks/overview#the-shared-loader-module).
  </Step>

  <Step title="Create the plugin">
    ```ts src/plugins/claimr.ts expandable theme={"dark"}
    import { App, InjectionKey, reactive, watch } from 'vue';
    import {
      ClaimrCampaignInfo,
      ClaimrConfig,
      ClaimrUser,
      getClaimr,
      loadClaimr,
    } from '../lib/claimr';

    export const CLAIMR_CONTAINER_ID = 'claimr-widget';

    export interface ClaimrState {
      /** The script has loaded and window.claimr exists. */
      loaded: boolean;
      error: string | null;
      /** The user signed in to the widget, or null. */
      user: ClaimrUser | null;
      campaign: ClaimrCampaignInfo | null;
      /** User token from your server. Set it through useClaimr().setUserToken(). */
      userToken: string | null;
    }

    export const claimrKey: InjectionKey<ClaimrState> = Symbol('claimr');

    export function createClaimr(options: Partial<ClaimrConfig> = {}) {
      const state = reactive<ClaimrState>({
        loaded: false,
        error: null,
        user: null,
        campaign: null,
        userToken: null,
      });

      return {
        install(app: App) {
          app.provide(claimrKey, state);

          loadClaimr(
            {
              organization: import.meta.env.VITE_CLAIMR_ORGANIZATION,
              campaign: import.meta.env.VITE_CLAIMR_CAMPAIGN,
              container: CLAIMR_CONTAINER_ID,
              autoresize: true,
              ...options,
            },
            // Runs on every fresh SDK instance. This is the only place callbacks are assigned.
            (claimr) => {
              claimr.on_user_info = (info) => (state.user = info);
              claimr.on_campaign_info = (info) => (state.campaign = info);
              claimr.on_logout = () => (state.user = null);
            },
          )
            .then(() => (state.loaded = true))
            .catch((err: Error) => (state.error = err.message));

          // Hand the user token to the widget once the script is loaded, and whenever it changes.
          watch(
            () => [state.loaded, state.userToken] as const,
            ([loaded, token]) => {
              if (loaded && token) getClaimr()?.set_user_token(token);
            },
          );
        },
      };
    }
    ```
  </Step>

  <Step title="Create the composable">
    ```ts src/composables/useClaimr.ts theme={"dark"}
    import { inject, toRefs } from 'vue';
    import { getClaimr, withWidget } from '../lib/claimr';
    import { claimrKey } from '../plugins/claimr';

    export function useClaimr() {
      const state = inject(claimrKey);
      if (!state) throw new Error('useClaimr() needs app.use(createClaimr())');

      return {
        ...toRefs(state), // loaded, error, user, campaign, userToken as refs
        openQuest: (questId: string) => getClaimr()?.open_quest(questId),
        login: () => getClaimr()?.login(),
        completeTask: (taskId: string) => withWidget((c) => c.complete_task(taskId)),
        setLanguage: (code: string) => withWidget((c) => c.set_language(code)),
        setTheme: (theme: string) => withWidget((c) => c.set_theme(theme)),
        /** Pass the token for the signed-in user, or null when they sign out. */
        setUserToken: (token: string | null) => {
          state.userToken = token;
          if (!token) getClaimr()?.logout();
        },
      };
    }
    ```
  </Step>

  <Step title="Create the widget component">
    ```vue src/components/ClaimrWidget.vue theme={"dark"}
    <script setup lang="ts">
    import { onUnmounted } from 'vue';
    import { CLAIMR_CONTAINER_ID } from '../plugins/claimr';

    onUnmounted(() => {
      // An open quest popup locks page scroll. Release it if the user navigates away mid-quest.
      document.body.style.overflow = '';
    });
    </script>

    <template>
      <!-- Keep this element empty and do not bind an inline height: the loader owns both. -->
      <div :id="CLAIMR_CONTAINER_ID" class="claimr-widget" />
    </template>
    ```

    <Warning>
      Render at most one `ClaimrWidget` at a time, and do not wrap it in `<KeepAlive>`. A kept-alive component is moved out of the document when it is deactivated, so the loader detaches the widget and reloads it on every reactivation.
    </Warning>
  </Step>

  <Step title="Install the plugin">
    ```ts src/main.ts theme={"dark"}
    import { createApp } from 'vue';
    import App from './App.vue';
    import router from './router';
    import { createClaimr } from './plugins/claimr';

    createApp(App).use(router).use(createClaimr()).mount('#app');
    ```

    Pass options to override the defaults, for example `createClaimr({ language: 'fr', platform: 'dapp' })`.
  </Step>

  <Step title="Render the widget">
    ```vue src/views/QuestsView.vue theme={"dark"}
    <script setup lang="ts">
    import ClaimrWidget from '../components/ClaimrWidget.vue';
    import { useClaimr } from '../composables/useClaimr';

    const { user, error } = useClaimr();
    </script>

    <template>
      <section>
        <h1>Quests</h1>
        <p v-if="user">Signed in to Claimr</p>
        <p v-if="error">The quests could not be loaded. Disable your ad blocker and reload the page.</p>
        <ClaimrWidget />
      </section>
    </template>
    ```

    With Vue Router, other views do not render the container, so the loader detaches the widget when you leave this route and attaches it when you come back.
  </Step>
</Steps>

### Use the SDK from components

```vue src/components/LessonVideo.vue theme={"dark"}
<script setup lang="ts">
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useClaimr } from '../composables/useClaimr';

const props = defineProps<{ taskId: string }>();
const { completeTask, setLanguage } = useClaimr();
const { locale } = useI18n();

// Follow the app's language.
watch(locale, (code) => setLanguage(code), { immediate: true });
</script>

<template>
  <!-- Mark a front-end task as complete when the user finishes the video. -->
  <video src="/lesson.mp4" controls @ended="completeTask(props.taskId)" />
</template>
```

`complete_task` only works for tasks configured as front-end tasks in the campaign. See [SDK tasks](/tasks/sdk-tasks).

### Sign users in with your own accounts

Generate the token on your backend with your secret API token. Never call the token endpoint from Vue code.

```ts server/claimr-token.ts theme={"dark"}
export async function getClaimrUserToken(account: string, name?: string): Promise<string> {
  const url = new URL('https://prod.claimr.io/api/v1/token');
  url.searchParams.set('account', account); // your stable user ID
  url.searchParams.set('platform', process.env.CLAIMR_PLATFORM!); // a short name for your platform, not "claimr"
  if (name) url.searchParams.set('name', name);

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CLAIMR_API_TOKEN}` },
  });
  const body = await response.json();
  if (!response.ok || !body.success) throw new Error('Claimr token request failed');
  return body.data.token as string;
}
```

Expose it through an authenticated endpoint of your own, then pass the token to the composable whenever your app's user changes:

```ts src/App.vue theme={"dark"}
// Inside <script setup lang="ts">
import { watch } from 'vue';
import { useClaimr } from './composables/useClaimr';
import { useAuthStore } from './stores/auth'; // your app's auth store

const auth = useAuthStore();
const { setUserToken } = useClaimr();

watch(
  () => auth.user?.id,
  async (id) => {
    if (!id) return setUserToken(null);
    const { token } = await fetch('/api/claimr-token', { credentials: 'include' }).then((r) => r.json());
    setUserToken(token);
  },
  { immediate: true },
);
```

Enable the matching sign-in option in the campaign under **Settings** > **Sign-in options**. See [User token](/api-guide/user-token).

<Tip>
  Using Pinia? You can keep `ClaimrState` in a Pinia store instead of `provide`/`inject`. Keep the rule the same: the plugin that loads the script is the only code that assigns SDK callbacks, and it writes into the store.
</Tip>

## Nuxt

This setup works with Nuxt 3 and Nuxt 4. Paths are relative to your source directory: the project root in Nuxt 3, or `app/` in Nuxt 4. Server files always live in `server/` at the project root.

| File | Runs on | Purpose |
| - | - | - |
| `lib/claimr.ts` | Browser | Framework-agnostic loader, readiness helpers, and TypeScript types. |
| `composables/useClaimr.ts` | Both | Shared state through `useState`, plus SDK methods. Auto-imported. |
| `plugins/claimr.client.ts` | Browser | Loads the script after hydration and assigns callbacks. |
| `components/ClaimrWidget.vue` | Both | Renders the container. Auto-imported. |
| `server/api/claimr/token.get.ts` | Server | Returns a user token to the signed-in user. Optional. |

<Steps>
  <Step title="Add runtime config">
    ```ts nuxt.config.ts theme={"dark"}
    export default defineNuxtConfig({
      runtimeConfig: {
        // Server only. Set with NUXT_CLAIMR_API_TOKEN and NUXT_CLAIMR_PLATFORM.
        claimrApiToken: '',
        claimrPlatform: '',
        public: {
          // Browser. Set with NUXT_PUBLIC_CLAIMR_ORGANIZATION and NUXT_PUBLIC_CLAIMR_CAMPAIGN.
          claimrOrganization: '',
          claimrCampaign: '',
        },
      },
    });
    ```

    ```bash .env theme={"dark"}
    NUXT_PUBLIC_CLAIMR_ORGANIZATION=your-organization-id
    NUXT_PUBLIC_CLAIMR_CAMPAIGN=your-campaign-id
    NUXT_CLAIMR_API_TOKEN=your-secret-api-token
    NUXT_CLAIMR_PLATFORM=your-platform-name
    ```
  </Step>

  <Step title="Add the shared loader module">
    Create `lib/claimr.ts` with the contents of [the shared loader module](/frameworks/overview#the-shared-loader-module). It guards every `window` access, so importing it during server rendering is safe.
  </Step>

  <Step title="Create the composable">
    `useState` gives you state that is shared across components and safe to use during server rendering.

    ```ts composables/useClaimr.ts theme={"dark"}
    import { getClaimr, withWidget, type ClaimrCampaignInfo, type ClaimrUser } from '~/lib/claimr';

    export const CLAIMR_CONTAINER_ID = 'claimr-widget';

    export interface ClaimrState {
      loaded: boolean;
      error: string | null;
      user: ClaimrUser | null;
      campaign: ClaimrCampaignInfo | null;
      userToken: string | null;
    }

    export const useClaimrState = () =>
      useState<ClaimrState>('claimr', () => ({
        loaded: false,
        error: null,
        user: null,
        campaign: null,
        userToken: null,
      }));

    export function useClaimr() {
      const state = useClaimrState();

      return {
        state, // Ref<ClaimrState>: use state.value.user, state.value.loaded, and so on
        openQuest: (questId: string) => getClaimr()?.open_quest(questId),
        login: () => getClaimr()?.login(),
        completeTask: (taskId: string) => withWidget((c) => c.complete_task(taskId)),
        setLanguage: (code: string) => withWidget((c) => c.set_language(code)),
        setTheme: (theme: string) => withWidget((c) => c.set_theme(theme)),
        setUserToken: (token: string | null) => {
          state.value.userToken = token;
          if (!token) getClaimr()?.logout();
        },
      };
    }
    ```
  </Step>

  <Step title="Create the client plugin">
    The `.client` suffix keeps the plugin out of server rendering. `onNuxtReady` delays loading until hydration is finished, so the loader never adds its iframe to server-rendered markup that Vue is still hydrating.

    ```ts plugins/claimr.client.ts theme={"dark"}
    import { getClaimr, loadClaimr } from '~/lib/claimr';

    export default defineNuxtPlugin(() => {
      const config = useRuntimeConfig();
      const state = useClaimrState();

      onNuxtReady(() => {
        loadClaimr(
          {
            organization: config.public.claimrOrganization,
            campaign: config.public.claimrCampaign,
            container: CLAIMR_CONTAINER_ID,
            autoresize: true,
          },
          (claimr) => {
            claimr.on_user_info = (info) => (state.value.user = info);
            claimr.on_campaign_info = (info) => (state.value.campaign = info);
            claimr.on_logout = () => (state.value.user = null);
          },
        )
          .then(() => (state.value.loaded = true))
          .catch((err: Error) => (state.value.error = err.message));
      });

      watch(
        () => [state.value.loaded, state.value.userToken] as const,
        ([loaded, token]) => {
          if (loaded && token) getClaimr()?.set_user_token(token);
        },
      );
    });
    ```
  </Step>

  <Step title="Create the widget component">
    ```vue components/ClaimrWidget.vue theme={"dark"}
    <script setup lang="ts">
    import { CLAIMR_CONTAINER_ID } from '~/composables/useClaimr';

    onUnmounted(() => {
      // An open quest popup locks page scroll. Release it if the user navigates away mid-quest.
      document.body.style.overflow = '';
    });
    </script>

    <template>
      <!-- Keep this element empty and do not bind an inline height: the loader owns both. -->
      <div :id="CLAIMR_CONTAINER_ID" class="claimr-widget" />
    </template>
    ```

    There is no need for `<ClientOnly>`. The empty `<div>` renders on the server, and the widget attaches to it in the browser.
  </Step>

  <Step title="Render the widget on a page">
    ```vue pages/quests.vue theme={"dark"}
    <script setup lang="ts">
    const { state } = useClaimr();
    </script>

    <template>
      <main>
        <h1>Quests</h1>
        <p v-if="state.user">Signed in to Claimr</p>
        <ClaimrWidget />
      </main>
    </template>
    ```

    Navigating with `<NuxtLink>` to a page without `ClaimrWidget` detaches the widget. Coming back attaches it again.
  </Step>
</Steps>

### Sign users in with your own accounts (Nuxt)

<Steps>
  <Step title="Create the server route">
    The route reads your secret API token from server-only runtime config. This example uses [`nuxt-auth-utils`](https://github.com/atinux/nuxt-auth-utils); replace `requireUserSession` with your auth library's equivalent.

    ```ts server/api/claimr/token.get.ts theme={"dark"}
    export default defineEventHandler(async (event) => {
      const { user } = await requireUserSession(event);
      const config = useRuntimeConfig(event);

      const response = await $fetch<{ success: boolean; data: { token: string } }>(
        'https://prod.claimr.io/api/v1/token',
        {
          query: { account: user.id, platform: config.claimrPlatform, name: user.name },
          headers: { Authorization: `Bearer ${config.claimrApiToken}` },
        },
      );

      return { token: response.data.token };
    });
    ```

    The token never expires. Store it with your user record to avoid calling Claimr on every request.
  </Step>

  <Step title="Pass the token to the widget">
    ```ts plugins/claimr-auth.client.ts theme={"dark"}
    export default defineNuxtPlugin(() => {
      const { loggedIn } = useUserSession(); // from nuxt-auth-utils, or your auth library
      const { setUserToken } = useClaimr();

      watch(
        loggedIn,
        async (isLoggedIn) => {
          if (!isLoggedIn) return setUserToken(null);
          const { token } = await $fetch('/api/claimr/token');
          setUserToken(token);
        },
        { immediate: true },
      );
    });
    ```
  </Step>
</Steps>

Enable the matching sign-in option in the campaign under **Settings** > **Sign-in options**.

## Wallets and Telegram

* **dApp with its own wallet**: pass `platform: 'dapp'` in the loader config and assign `window.claimr.on_wallet_request` once your wallet library is ready. The handler receives `{ op, ...params }` for `connect`, `sign_message`, `send_transaction`, and `disconnect`, and must return a result or throw. See [`on_wallet_request`](/sdk-guide#on_wallet_request) and the [React wallet bridge](/frameworks/react#connect-the-users-wallet-dapps), which translates directly to a Vue component using `@wagmi/vue`.
* **Telegram Mini App**: pass `platform: 'telegram'`. See [Telegram Mini App](/how-to/integrating-claimr-widget-into-telegram-mini-app).

## Verify the integration

1. Open the page with `ClaimrWidget`. The campaign renders inside the container.
2. In the browser console, `window.claimr.is_claimr_ready` returns `true`.
3. Navigate to another route and back. The widget comes back, and `document.querySelectorAll('#claimr-script').length` is still `1`.
4. In Nuxt, the server log and browser console show no hydration mismatch warnings for the widget container.
5. If you use user tokens, sign in to your app and confirm the widget shows the same user without a second sign-in.
