Next.js (frontend)
Browser-only Next.js that talks to a separate API — public client, PKCE, no server-side login.
When your Next.js app is purely a frontend that talks to a separate backend API, it is a public client. The browser runs the PKCE flow, and the access token goes to your API on every request. The Next.js server does not handle auth callbacks; the browser does.
If your Next.js server runs the login itself (/api/auth/callback lives on
the same server as your pages), use the
fullstack guide instead. The two
shapes look similar but use different env variables and different SDK
entry points.
Scaffold
npx base-idp create \
--stack nextjs \
--client-id sq_live_webThis writes lib/auth.ts, a .env.local, and a setup readme into
./base-idp-nextjs/.
Install
npm install base-idpConfigure
NEXT_PUBLIC_BASE_IDP_CLIENT_ID=sq_live_webThe variable is prefixed with NEXT_PUBLIC_ because the browser bundle
needs to read it. A client id is safe to ship in the bundle — it is the
public identifier of your app.
The auth client
"use client";
import { createBrowserBaseIdpAuth } from "base-idp/browser";
export const auth = createBrowserBaseIdpAuth({
clientId: process.env.NEXT_PUBLIC_BASE_IDP_CLIENT_ID!,
});The "use client" directive is important — this file imports from
base-idp/browser, which is browser-only code.
A sign-in button
"use client";
import { auth } from "@/lib/auth";
export default function SignInPage() {
async function onClick() {
await auth.loginWithRedirect({
returnTo: "/dashboard",
});
}
return (
<main>
<h1>Sign in</h1>
<button onClick={onClick}>Continue with Base IdP</button>
</main>
);
}loginWithRedirect stores the PKCE verifier and the returnTo URL in
session storage, then navigates to Base IdP.
A callback page
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { auth } from "@/lib/auth";
export default function CallbackPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
auth
.handleCallback()
.then((result) => {
// Persist the tokens however you like — localStorage, IndexedDB,
// a service worker, or send them to your API to mint a session.
localStorage.setItem("access_token", result.tokens.access_token);
localStorage.setItem("refresh_token", result.tokens.refresh_token);
router.replace(result.returnTo ?? "/");
})
.catch((e) => setError(e.message));
}, [router]);
if (error) return <p>Sign-in failed: {error}</p>;
return <p>Signing you in...</p>;
}handleCallback reads the code and state from the URL, exchanges the code
for tokens (PKCE — no secret needed), and returns the session.
Register the redirect URI
Add <your-origin>/auth/callback to your app's allowed redirect URIs in
Square Experience Cloud.
For local dev:
http://localhost:3000/auth/callbackFor production:
https://app.example.com/auth/callbackCalling your API
Use the access token as a Bearer token. Your backend (Express, Go, Rust,
NestJS) verifies it.
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const accessToken = localStorage.getItem("access_token");
if (!accessToken) throw new Error("not signed in");
const response = await fetch(`https://api.example.com${path}`, {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) throw new Error(`request failed: ${response.status}`);
return response.json();
}Refresh
When the access token expires, refresh it.
import { auth } from "./auth";
export async function refresh() {
const refreshToken = localStorage.getItem("refresh_token");
if (!refreshToken) return false;
try {
const tokens = await auth.refreshTokens(refreshToken);
localStorage.setItem("access_token", tokens.access_token);
localStorage.setItem("refresh_token", tokens.refresh_token);
return true;
} catch {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
return false;
}
}Refresh tokens rotate on every use. Always write the new one back.
Logout
async function signOut() {
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
try {
await auth.logout(refreshToken);
} catch {
// best-effort
}
}
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/";
}Where to keep the tokens
Browser-only token storage is always a tradeoff. The options:
| Where | Pros | Cons |
|---|---|---|
localStorage | Simple, survives reloads. | Accessible to any JS on the origin — XSS exposes tokens. |
sessionStorage | Cleared on tab close. | Same XSS risk, plus does not survive reloads. |
IndexedDB | More storage, async. | Same XSS risk. |
| HTTP-only cookie via a tiny callback API | Not accessible to JS. | Requires a callback API on your origin. |
If your app's frontend and backend are not the same origin, an HTTP-only
cookie is awkward and localStorage is the realistic option. Make sure your
app has solid Content Security Policy and trusted-types coverage to mitigate
XSS.
Common issues
handleCallback throws "missing state"
The callback page was loaded directly, without going through
loginWithRedirect. State is only stored when the SDK starts the flow.
Tokens disappear when the user clicks a link
You stored them in sessionStorage, which is per-tab. Use localStorage or
your own persistence layer.
CORS errors when calling the API
Your API needs to allow the origin your Next.js app runs on, and allow the
Authorization header. This is a backend CORS configuration, not a Base
IdP issue.