Framework guides

Sign in with Paspo in plain HTML, React, Next.js, Vue, Nuxt, Angular and Svelte.

Every integration has the same three parts:

  1. A button that calls signIn from its click handler.
  2. A callback page at your redirectUri. The SDK uses it only when the browser blocks the popup and the whole tab goes to Paspo and back.
  3. A backend route that exchanges the code — see Server. The examples below post to POST /auth/paspo with:
{ "code": "…", "code_verifier": "…", "nonce": "…", "via": "fedcm" }
Call signIn synchronously

Call signIn directly in the click handler. An await before it (fetching config, checking a session) makes the browser treat the popup as unsolicited and block it. Load anything you need before the click.

Same origin

redirectUri must be on the same origin as the page with the button: the Paspo window delivers the result to that origin. https://app.example.com button → https://app.example.com/auth/paspo/callback.

Plain HTML

No build step — the CDN script and a few lines of JavaScript.

index.html
<button id="paspo-signin" type="button">Sign in with Paspo</button>

<script src="https://paspo.id/sdk/paspo.min.js"></script>
<script>
  const CLIENT_ID = "YOUR_CLIENT_ID";
  const REDIRECT_URI = location.origin + "/auth/paspo/callback";

  async function exchange({ code, state, via }) {
    const response = await fetch("/auth/paspo", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        code,
        code_verifier: Paspo.takeVerifier(state),
        nonce: Paspo.takeNonce(state),
        via,
      }),
    });
    if (!response.ok) throw new Error("Sign-in failed");
  }

  const button = document.getElementById("paspo-signin");
  button.addEventListener("click", () => {
    button.disabled = true;
    Paspo.signIn({
      clientId: CLIENT_ID,
      redirectUri: REDIRECT_URI,
      scope: "openid email",
      onSuccess: (result) =>
        exchange(result)
          .then(() => location.assign("/"))
          .catch(() => (button.disabled = false)),
      onError: (error) => {
        button.disabled = false;
        console.error(error.error, error.error_description);
      },
      onClose: () => (button.disabled = false),
    });
  });
</script>
auth/paspo/callback.html
<script src="https://paspo.id/sdk/paspo.min.js"></script>
<script>
  const params = new URLSearchParams(location.search);
  const code = params.get("code");
  const state = params.get("state");

  if (code) {
    fetch("/auth/paspo", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        code,
        code_verifier: Paspo.takeVerifier(state),
        nonce: Paspo.takeNonce(state),
      }),
    }).then((response) => location.replace(response.ok ? "/" : "/login?error=paspo"));
  } else {
    location.replace("/login?error=" + encodeURIComponent(params.get("error") || "paspo"));
  }
</script>

React

Works with Vite, Create React App, Remix and any React 17+ setup.

npm install @pingocean/paspo-sdk
src/paspo.ts
import { signIn, takeNonce, takeVerifier } from "@pingocean/paspo-sdk";

export const PASPO_CLIENT_ID = import.meta.env.VITE_PASPO_CLIENT_ID;
export const PASPO_REDIRECT_URI = `${window.location.origin}/auth/paspo/callback`;

export async function exchangePaspoCode({ code, state, via }: { code: string; state: string; via?: string }) {
  const response = await fetch("/auth/paspo", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify({ code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
  });
  if (!response.ok) throw new Error("Paspo sign-in failed");
  return response.json();
}

export { signIn };
src/PaspoButton.tsx
import { useState } from "react";
import { PASPO_CLIENT_ID, PASPO_REDIRECT_URI, exchangePaspoCode, signIn } from "./paspo";

export function PaspoButton({ onSignedIn }: { onSignedIn: (user: unknown) => void }) {
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState("");

  const handleClick = () => {
    setBusy(true);
    setError("");
    // No await before signIn — keeps the popup allowed.
    signIn({
      clientId: PASPO_CLIENT_ID,
      redirectUri: PASPO_REDIRECT_URI,
      scope: "openid email",
      onSuccess: (result) =>
        exchangePaspoCode(result)
          .then(onSignedIn)
          .catch((e) => setError(e.message))
          .finally(() => setBusy(false)),
      onError: (e) => {
        setError(e.error_description || e.error);
        setBusy(false);
      },
      onClose: () => setBusy(false),
    });
  };

  return (
    <>
      <button type="button" onClick={handleClick} disabled={busy}>
        Sign in with Paspo
      </button>
      {error && <p role="alert">{error}</p>}
    </>
  );
}
src/PaspoCallback.tsx
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { exchangePaspoCode } from "./paspo";

// Route: /auth/paspo/callback
export function PaspoCallback() {
  const navigate = useNavigate();
  const started = useRef(false);

  useEffect(() => {
    // StrictMode runs effects twice; the verifier can be read only once.
    if (started.current) return;
    started.current = true;

    const params = new URLSearchParams(window.location.search);
    const code = params.get("code");
    const state = params.get("state") ?? "";
    if (!code) {
      navigate(`/login?error=${params.get("error") ?? "paspo"}`, { replace: true });
      return;
    }
    exchangePaspoCode({ code, state })
      .then(() => navigate("/", { replace: true }))
      .catch(() => navigate("/login?error=paspo", { replace: true }));
  }, [navigate]);

  return <p>Signing you in…</p>;
}

Next.js

App Router. The button and the callback are client components; the code exchange is a route handler.

npm install @pingocean/paspo-sdk
.env.local
NEXT_PUBLIC_PASPO_CLIENT_ID=YOUR_CLIENT_ID
PASPO_CLIENT_SECRET=YOUR_CLIENT_SECRET   # server only, no NEXT_PUBLIC_ prefix
PASPO_REDIRECT_URI=https://app.example.com/auth/paspo/callback
app/components/paspo-button.tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";
import { signIn, takeNonce, takeVerifier } from "@pingocean/paspo-sdk";

export async function exchangePaspoCode(code: string, state: string, via?: string) {
  const response = await fetch("/api/auth/paspo", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
  });
  if (!response.ok) throw new Error("Paspo sign-in failed");
}

export function PaspoButton() {
  const router = useRouter();
  const [busy, setBusy] = useState(false);

  return (
    <button
      type="button"
      disabled={busy}
      onClick={() => {
        setBusy(true);
        signIn({
          clientId: process.env.NEXT_PUBLIC_PASPO_CLIENT_ID!,
          redirectUri: `${window.location.origin}/auth/paspo/callback`,
          scope: "openid email",
          onSuccess: ({ code, state, via }) =>
            exchangePaspoCode(code, state, via)
              .then(() => router.refresh())
              .finally(() => setBusy(false)),
          onError: () => setBusy(false),
          onClose: () => setBusy(false),
        });
      }}
    >
      Sign in with Paspo
    </button>
  );
}
app/auth/paspo/callback/page.tsx
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef } from "react";
import { exchangePaspoCode } from "@/app/components/paspo-button";

function Callback() {
  const router = useRouter();
  const params = useSearchParams();
  const started = useRef(false);

  useEffect(() => {
    if (started.current) return;
    started.current = true;
    const code = params.get("code");
    if (!code) return router.replace(`/login?error=${params.get("error") ?? "paspo"}`);
    exchangePaspoCode(code, params.get("state") ?? "")
      .then(() => router.replace("/"))
      .catch(() => router.replace("/login?error=paspo"));
  }, [params, router]);

  return <p>Signing you in…</p>;
}

export default function Page() {
  return (
    <Suspense>
      <Callback />
    </Suspense>
  );
}

Vue 3

Vite + Vue Router.

npm install @pingocean/paspo-sdk
src/composables/usePaspo.ts
import { ref } from "vue";
import { signIn, takeNonce, takeVerifier } from "@pingocean/paspo-sdk";

const CLIENT_ID = import.meta.env.VITE_PASPO_CLIENT_ID;

export async function exchangePaspoCode(code: string, state: string, via?: string) {
  const response = await fetch("/auth/paspo", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify({ code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
  });
  if (!response.ok) throw new Error("Paspo sign-in failed");
  return response.json();
}

export function usePaspo() {
  const busy = ref(false);
  const error = ref("");

  function start(onSignedIn: (user: unknown) => void) {
    busy.value = true;
    error.value = "";
    signIn({
      clientId: CLIENT_ID,
      redirectUri: `${window.location.origin}/auth/paspo/callback`,
      scope: "openid email",
      onSuccess: ({ code, state, via }) =>
        exchangePaspoCode(code, state, via)
          .then(onSignedIn)
          .catch((e) => (error.value = e.message))
          .finally(() => (busy.value = false)),
      onError: (e) => {
        error.value = e.error_description || e.error;
        busy.value = false;
      },
      onClose: () => (busy.value = false),
    });
  }

  return { busy, error, start };
}
src/components/PaspoButton.vue
<script setup lang="ts">
import { useRouter } from "vue-router";
import { usePaspo } from "@/composables/usePaspo";

const router = useRouter();
const { busy, error, start } = usePaspo();
</script>

<template>
  <button type="button" :disabled="busy" @click="start(() => router.push('/'))">
    Sign in with Paspo
  </button>
  <p v-if="error" role="alert">{{ error }}</p>
</template>
src/views/PaspoCallback.vue
<script setup lang="ts">
// Route: /auth/paspo/callback
import { onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { exchangePaspoCode } from "@/composables/usePaspo";

const route = useRoute();
const router = useRouter();

onMounted(async () => {
  const code = route.query.code as string | undefined;
  if (!code) return router.replace({ path: "/login", query: { error: route.query.error ?? "paspo" } });
  try {
    await exchangePaspoCode(code, String(route.query.state ?? ""));
    router.replace("/");
  } catch {
    router.replace({ path: "/login", query: { error: "paspo" } });
  }
});
</script>

<template>
  <p>Signing you in…</p>
</template>

Nuxt

Nuxt 3 / 4. The exchange runs in a Nuxt server route; the client part is the same as Vue.

npm install @pingocean/paspo-sdk
nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    paspoClientSecret: "",            // NUXT_PASPO_CLIENT_SECRET — server only
    paspoRedirectUri: "",             // NUXT_PASPO_REDIRECT_URI
    public: { paspoClientId: "" },    // NUXT_PUBLIC_PASPO_CLIENT_ID
  },
});
components/PaspoButton.vue
<script setup lang="ts">
import { signIn, takeNonce, takeVerifier } from "@pingocean/paspo-sdk";

const { public: { paspoClientId } } = useRuntimeConfig();
const busy = ref(false);

function start() {
  busy.value = true;
  signIn({
    clientId: paspoClientId,
    redirectUri: `${window.location.origin}/auth/paspo/callback`,
    scope: "openid email",
    onSuccess: async ({ code, state, via }) => {
      try {
        await $fetch("/api/auth/paspo", {
          method: "POST",
          body: { code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via },
        });
        await navigateTo("/");
      } finally {
        busy.value = false;
      }
    },
    onError: () => (busy.value = false),
    onClose: () => (busy.value = false),
  });
}
</script>

<template>
  <button type="button" :disabled="busy" @click="start">Sign in with Paspo</button>
</template>
pages/auth/paspo/callback.vue
<script setup lang="ts">
import { takeNonce, takeVerifier } from "@pingocean/paspo-sdk";

const route = useRoute();

onMounted(async () => {
  const code = route.query.code as string | undefined;
  const state = String(route.query.state ?? "");
  if (!code) return navigateTo({ path: "/login", query: { error: route.query.error ?? "paspo" } }, { replace: true });
  try {
    await $fetch("/api/auth/paspo", {
      method: "POST",
      body: { code, code_verifier: takeVerifier(state), nonce: takeNonce(state) },
    });
    await navigateTo("/", { replace: true });
  } catch {
    await navigateTo({ path: "/login", query: { error: "paspo" } }, { replace: true });
  }
});
</script>

<template>
  <p>Signing you in…</p>
</template>

Angular

Angular 16+, standalone components.

npm install @pingocean/paspo-sdk
src/app/paspo.service.ts
import { HttpClient } from "@angular/common/http";
import { Injectable, inject } from "@angular/core";
import { firstValueFrom } from "rxjs";
import { signIn, takeNonce, takeVerifier, type PaspoSignInError } from "@pingocean/paspo-sdk";
import { environment } from "../environments/environment";

@Injectable({ providedIn: "root" })
export class PaspoService {
  private http = inject(HttpClient);

  /** Call from a (click) handler. */
  signIn(handlers: { done: () => void; failed: (error: PaspoSignInError | Error) => void; closed: () => void }) {
    signIn({
      clientId: environment.paspoClientId,
      redirectUri: `${window.location.origin}/auth/paspo/callback`,
      scope: "openid email",
      onSuccess: ({ code, state, via }) =>
        this.exchange(code, state, via).then(handlers.done, handlers.failed),
      onError: handlers.failed,
      onClose: handlers.closed,
    });
  }

  exchange(code: string, state: string, via?: string) {
    return firstValueFrom(
      this.http.post("/auth/paspo", { code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
    );
  }
}
src/app/paspo-button.component.ts
import { Component, NgZone, inject, signal } from "@angular/core";
import { Router } from "@angular/router";
import { PaspoService } from "./paspo.service";

@Component({
  selector: "app-paspo-button",
  standalone: true,
  template: `<button type="button" [disabled]="busy()" (click)="start()">Sign in with Paspo</button>`,
})
export class PaspoButtonComponent {
  private paspo = inject(PaspoService);
  private router = inject(Router);
  private zone = inject(NgZone);
  busy = signal(false);

  start() {
    this.busy.set(true);
    this.paspo.signIn({
      done: () => this.zone.run(() => this.router.navigateByUrl("/")),
      failed: () => this.busy.set(false),
      closed: () => this.busy.set(false),
    });
  }
}
src/app/paspo-callback.component.ts
import { Component, OnInit, inject } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { PaspoService } from "./paspo.service";

// Route: { path: "auth/paspo/callback", component: PaspoCallbackComponent }
@Component({ standalone: true, template: `<p>Signing you in…</p>` })
export class PaspoCallbackComponent implements OnInit {
  private route = inject(ActivatedRoute);
  private router = inject(Router);
  private paspo = inject(PaspoService);

  async ngOnInit() {
    const query = this.route.snapshot.queryParamMap;
    const code = query.get("code");
    if (!code) {
      this.router.navigate(["/login"], { queryParams: { error: query.get("error") ?? "paspo" } });
      return;
    }
    try {
      await this.paspo.exchange(code, query.get("state") ?? "");
      this.router.navigateByUrl("/");
    } catch {
      this.router.navigate(["/login"], { queryParams: { error: "paspo" } });
    }
  }
}

Svelte / SvelteKit

npm install @pingocean/paspo-sdk
src/lib/paspo.ts
import { PUBLIC_PASPO_CLIENT_ID } from "$env/static/public";
import { signIn, takeNonce, takeVerifier, type PaspoSignInOptions } from "@pingocean/paspo-sdk";

export async function exchangePaspoCode(code: string, state: string, via?: string) {
  const response = await fetch("/auth/paspo", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code, code_verifier: takeVerifier(state), nonce: takeNonce(state), via }),
  });
  if (!response.ok) throw new Error("Paspo sign-in failed");
}

export function startPaspo(handlers: Pick<PaspoSignInOptions, "onSuccess" | "onError" | "onClose">) {
  signIn({
    clientId: PUBLIC_PASPO_CLIENT_ID,
    redirectUri: `${location.origin}/auth/paspo/callback`,
    scope: "openid email",
    ...handlers,
  });
}
src/lib/PaspoButton.svelte
<script lang="ts">
  import { goto } from "$app/navigation";
  import { exchangePaspoCode, startPaspo } from "$lib/paspo";

  let busy = false;

  function start() {
    busy = true;
    startPaspo({
      onSuccess: ({ code, state, via }) =>
        exchangePaspoCode(code, state, via)
          .then(() => goto("/"))
          .finally(() => (busy = false)),
      onError: () => (busy = false),
      onClose: () => (busy = false),
    });
  }
</script>

<button type="button" disabled={busy} on:click={start}>Sign in with Paspo</button>
src/routes/auth/paspo/callback/+page.svelte
<script lang="ts">
  import { onMount } from "svelte";
  import { goto } from "$app/navigation";
  import { page } from "$app/stores";
  import { exchangePaspoCode } from "$lib/paspo";

  onMount(async () => {
    const query = $page.url.searchParams;
    const code = query.get("code");
    if (!code) return goto(`/login?error=${query.get("error") ?? "paspo"}`, { replaceState: true });
    try {
      await exchangePaspoCode(code, query.get("state") ?? "");
      goto("/", { replaceState: true });
    } catch {
      goto("/login?error=paspo", { replaceState: true });
    }
  });
</script>

<p>Signing you in…</p>

Other frameworks

The SDK is framework-agnostic: anything that can run a click handler in the browser works — Solid, Preact, Qwik, Astro islands, Alpine, jQuery, Blazor (via JS interop), Django / Rails / Laravel templates with the CDN script. Follow the Plain HTML example.

Prefer not to add a frontend dependency at all? Use a full-page redirect handled by your server — see Manual integration.