Base IdP

Base IdP

Frameworks

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_web

This writes lib/auth.ts, a .env.local, and a setup readme into ./base-idp-nextjs/.

Install

npm install base-idp

Configure

.env.local
NEXT_PUBLIC_BASE_IDP_CLIENT_ID=sq_live_web

The 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

lib/auth.ts
"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

app/(public)/sign-in/page.tsx
"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

app/auth/callback/page.tsx
"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/callback

For production:

https://app.example.com/auth/callback

Calling your API

Use the access token as a Bearer token. Your backend (Express, Go, Rust, NestJS) verifies it.

lib/api.ts
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.

lib/refresh.ts
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:

WhereProsCons
localStorageSimple, survives reloads.Accessible to any JS on the origin — XSS exposes tokens.
sessionStorageCleared on tab close.Same XSS risk, plus does not survive reloads.
IndexedDBMore storage, async.Same XSS risk.
HTTP-only cookie via a tiny callback APINot 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.

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.

Where to go next

On this page