SUPER EARLY WIP — USE AT YOUR OWN RISK■
shoo
GitHubCOMING SOON

Getting Started (React + Next.js)

Sign in with React and call a protected Next.js API route

This guide uses the Next.js App Router. At the end, a signed-in user calls /api/me and your server returns their verified user ID.

1. Install

npm install @shoojs/react @shoojs/auth

2. Set your app origin

.env.local
APP_ORIGIN=http://localhost:3000

In production, set APP_ORIGIN to your exact production origin, for example https://app.example.com. Each origin is a separate Shoo app with separate user IDs. Read identity and privacy first.

3. Add the callback page

@shoojs/react uses /auth/callback by default. Next.js needs a page there:

app/auth/callback/page.tsx
"use client";

import { useShooAuth } from "@shoojs/react";

export default function ShooCallback() {
  const { error } = useShooAuth();
  if (error) {
    return (
      <p>
        Sign-in was cancelled or failed. <a href="/">Go back</a>
      </p>
    );
  }
  return <p>Signing in…</p>;
}

The hook exchanges the code once, saves the token, and returns to the page where sign-in started.

Use a dedicated callback path. Do not reuse a page that does other work or forwards its query string. Shoo supports /auth/callback and /shoo/callback.

4. Add sign-in

app/account.tsx
"use client";

import { useState } from "react";
import { useShooAuth } from "@shoojs/react";

export function Account() {
  const { identity, status, error, signIn, clearIdentity } = useShooAuth();

  function startSignIn() {
    // If sign-in cannot start, the hook sets `error`.
    signIn().catch(() => undefined);
  }

  if (status === "loading") return <p>Loading…</p>;

  return (
    <div>
      {status === "signed_in" && identity.token ? (
        <>
          <Me key={identity.token} token={identity.token} onSignIn={startSignIn} />
          <button onClick={clearIdentity}>Sign out</button>
        </>
      ) : (
        <button onClick={startSignIn}>{status === "expired" ? "Sign in again" : "Sign in"}</button>
      )}
      {error && <p>Sign-in failed: {error}</p>}
    </div>
  );
}

// Render with `key={token}`. A new token remounts it, so old results and late
// responses for the previous token never show.
function Me({ token, onSignIn }: { token: string; onSignIn: () => void }) {
  const [message, setMessage] = useState<string | null>(null);
  const [rejected, setRejected] = useState(false);

  async function loadMe() {
    setMessage(null);
    try {
      const res = await fetch("/api/me", {
        headers: { Authorization: `Bearer ${token}` }
      });
      if (res.status === 401) {
        // Expired or revoked.
        setRejected(true);
        return;
      }
      if (!res.ok) {
        setMessage(`Request failed (${res.status}). Try again.`);
        return;
      }
      const body: { userId: string } = await res.json();
      setMessage(`Server says you are ${body.userId}`);
    } catch {
      // Network error or a response that is not JSON.
      setMessage("Could not load your account. Try again.");
    }
  }

  if (rejected) return <button onClick={onSignIn}>Sign in again</button>;
  return (
    <>
      <button onClick={() => void loadMe()}>Load my account</button>
      {message && <p>{message}</p>}
    </>
  );
}

Render <Account /> from any page.

Me does not call clearIdentity() on a 401. A late 401 for an old token could otherwise clear a newer sign-in from another tab.

5. Protect the API route

lib/shoo.ts
import { createShooVerifier } from "@shoojs/auth/server";

const appOrigin = process.env.APP_ORIGIN;
if (!appOrigin) throw new Error("APP_ORIGIN is not set");

export const verifyShooToken = createShooVerifier({
  appOrigin,
  allowLocalhost: process.env.NODE_ENV !== "production"
});
app/api/me/route.ts
import { verifyShooToken } from "@/lib/shoo";

export async function GET(request: Request) {
  const match = /^Bearer (\S+)$/.exec(request.headers.get("authorization") ?? "");
  if (!match) {
    return Response.json({ error: "unauthorized" }, { status: 401 });
  }

  try {
    const identity = await verifyShooToken(match[1]);
    // Use identity.userId as the user ID in your database.
    return Response.json({ userId: identity.userId });
  } catch {
    return Response.json({ error: "unauthorized" }, { status: 401 });
  }
}

For sensitive routes, use an online verifier. See server verification.

6. Try it

  1. Run npm run dev and open http://localhost:3000.
  2. Click Sign in. Shoo shows your app's origin. Continue with Google.
  3. You return to the page where you started.
  4. Click Load my account. The server returns your pairwise_sub.

Expiry and renewal

The token lasts one hour. After that, status becomes "expired" and /api/me returns 401. The user clicks Sign in again. If their Shoo session is still valid, they return without a Google prompt.

The hook does not renew tokens in the background. Read tokens and sessions.

Request profile data

signIn({ requestPii: true });

Shoo asks the user before it shares email, name, picture, and pii_sub. Only request this if you need it. See identity and privacy.

Without Next.js

The same hook works in Vite and other React apps. Your host must serve your app at /auth/callback. For a single-page app, add a rewrite so that path serves index.html. See SPA rewrites.

Next steps