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/auth2. Set your app origin
APP_ORIGIN=http://localhost:3000In 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:
"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
"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
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"
});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
- Run
npm run devand openhttp://localhost:3000. - Click Sign in. Shoo shows your app's origin. Continue with Google.
- You return to the page where you started.
- 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.