UseThatApp Logo
    JavaScript/React

    React

    Sign users into your React app with their UseThatApp account and gate features on their live plan, over standard OAuth 2.0 / OpenID Connect. This wiring is one end of the full selling loop: a buyer who purchases on your website through a UseThatApp buy link lands back in the app signed in, entitlement already live — and the same routes serve marketplace launches. The browser never touches the SDK: a tiny backend-for-frontend (BFF) runs the usethatapp SDK, and your React UI just calls it.

    ⚠ The SDK is server-only — never put it in the browser bundle.

    The usethatapp SDK is a confidential client: it holds your client secret and validates ID tokens. It must run on a server. A Vite/CRA React app has no server of its own, so you pair it with a small Node/Express backend-for-frontend that owns /login, /callback, /logout and /api/entitlement. The browser only ever calls that backend — the client secret and the SDK stay out of your bundle entirely.

    This guide covers the Hosted sign-in path

    It has UseThatApp run login, so buyers return signed in and the same session answers what they bought — the Hosted sign-in add-on. If your app already has its own accounts, you do not need any of the sign-in wiring below: keep your login and verify purchases with a license key your server validates in one call, included in the per-sale fee.

    Architecture

    1. BFF (server.js). A small Express server runs the SDK. It owns /login (redirect to UseThatApp), /callback (exchange the code for tokens), /logout, and /api/entitlement (read the live plan). Tokens live in a server-side session keyed by an httpOnly cookie.
    2. React SPA. The app calls fetch('/api/entitlement', { credentials: 'include' }) to read the plan and navigates to /login to sign in. It never sees a token.
    3. Vite proxy. In dev, Vite proxies /login, /callback, /logout and /api to the BFF so the browser talks to one origin.

    Prerequisites

    • Node.js 18+, server-side only (uses the global fetch and Node's node:crypto/node:fs — the SDK can never ship in your React bundle)
    • A Vite (or CRA) React app, plus a small Node/Express server alongside it
    • An OAuth client from your app's Integration page: UTA_CLIENT_ID and UTA_CLIENT_SECRET, with http://localhost:5173/callback registered as a redirect URI for development — see OpenID Connect

    Step 1 — Install

    terminal
    npm install usethatapp express cookie-parser dotenv
    .env (server-side only)
    UTA_CLIENT_ID=...            # required
    UTA_CLIENT_SECRET=...        # required (server-side only — never in the bundle)
    UTA_REDIRECT_URI=http://localhost:5173/callback
    # optional — default to production:
    UTA_ISSUER=https://www.usethatapp.com/o
    UTA_API_URL=https://www.usethatapp.com
    UTA_SCOPES="openid entitlements"

    Step 2 — The backend-for-frontend

    This is the only place the SDK runs. beginLogin() returns an authorization URL plus a JSON-serializable flowState — stash it in the session, redirect, then hand it back to completeLogin() in the callback. On cancel/deny the provider returns with ?error= and no code, so check that first.

    server.js
    import 'dotenv/config';
    import { randomBytes } from 'node:crypto';
    
    import cookieParser from 'cookie-parser';
    import express from 'express';
    import {
      beginLogin,
      completeLogin,
      configure,
      getEntitlement,
      logoutUrl,
      UtaError,
      UtaTokenError,
    } from 'usethatapp';
    
    // The SDK is a confidential, server-side client — it runs here in the BFF,
    // never in the browser. Reads UTA_* from the environment; configure() lets
    // you override per-process.
    configure({
      client_id: process.env.UTA_CLIENT_ID,
      client_secret: process.env.UTA_CLIENT_SECRET,
      redirect_uri: process.env.UTA_REDIRECT_URI,
    });
    
    const APP_URL = process.env.APP_URL ?? 'http://localhost:5173';
    const SESSION_COOKIE = 'uta_session';
    const sessions = new Map(); // in-memory; use a real store in production
    
    function getSession(req, res) {
      const sid = req.cookies?.[SESSION_COOKIE];
      if (sid && sessions.has(sid)) return sessions.get(sid);
      const id = randomBytes(24).toString('hex');
      const data = {};
      sessions.set(id, data);
      res.cookie(SESSION_COOKIE, id, { httpOnly: true, sameSite: 'lax', maxAge: 86_400_000 });
      return data;
    }
    const peekSession = (req) => sessions.get(req.cookies?.[SESSION_COOKIE]);
    
    const app = express();
    app.use(cookieParser());
    
    // Start sign-in. Also set this as your app's "Login URL" on the Integration page.
    app.get('/login', async (req, res, next) => {
      try {
        const session = getSession(req, res);
        const { authorizationUrl, flowState } = await beginLogin();
        session.flow = flowState;        // stash across the redirect
        res.redirect(authorizationUrl);
      } catch (e) { next(e); }
    });
    
    // Finish sign-in: exchange the code, store tokens, return to the app.
    app.get('/callback', async (req, res, next) => {
      const session = getSession(req, res);
      if (req.query.error) {             // user canceled/denied — no code
        delete session.flow;
        return res.redirect(303, '/');
      }
      try {
        const s = await completeLogin({
          code: req.query.code,
          state: req.query.state,
          flowState: session.flow,
        });
        delete session.flow;
        session.sub = s.sub;             // pairwise, per-app id — your user key
        session.accessToken = s.access_token;
        session.idToken = s.id_token;
        res.redirect(303, '/');
      } catch (e) {
        if (e instanceof UtaError) return res.redirect(303, '/'); // back, not signed in
        next(e);
      }
    });
    
    // Live entitlement for the signed-in user.
    app.get('/api/entitlement', async (req, res, next) => {
      const session = peekSession(req);
      if (!session?.accessToken) return res.status(401).json({ error: 'not signed in' });
      try {
        const entitlement = await getEntitlement(session.accessToken);
        res.json({ sub: session.sub, entitlement });
      } catch (e) {
        if (e instanceof UtaTokenError) {
          // Token revoked/expired (e.g. signed out of UseThatApp). Reconcile by
          // dropping it; the SPA falls back to the signed-out view.
          delete session.accessToken;
          delete session.sub;
          delete session.idToken;
          return res.status(401).json({ error: 'session ended' });
        }
        if (e instanceof UtaError) return res.status(502).json({ error: e.message });
        next(e);
      }
    });
    
    // Sign out — RP-initiated. Don't clear the session yet (see Sign out, below).
    app.get('/logout', async (req, res, next) => {
      const session = peekSession(req);
      try {
        res.redirect(await logoutUrl({
          idToken: session?.idToken,
          postLogoutRedirectUri: `${APP_URL}/`,
        }));
      } catch (e) { next(e); }
    });
    
    app.listen(3001, () => console.log('BFF on http://localhost:3001'));

    Step 3 — Proxy the BFF in Vite

    Proxy the auth and API routes to the BFF so the browser only ever talks to one origin (and cookies stay first-party).

    vite.config.ts
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      plugins: [react()],
      server: {
        proxy: {
          '/api': 'http://localhost:3001',
          '/login': 'http://localhost:3001',
          '/callback': 'http://localhost:3001',
          '/logout': 'http://localhost:3001',
        },
      },
    });

    Step 4 — Read the plan in React

    A hook fetches /api/entitlement once with credentials: 'include'. A 401 just means signed-out — render the "Sign in with UseThatApp" view. Gate logic on the stable product_public_id; show the mutable version (plan name) to the user.

    src/hooks/useAccessLevel.ts
    import { useEffect, useState } from 'react';
    
    export interface AccessState {
      loggedIn: boolean;
      loading: boolean;
      entitled: boolean;
      productId: string | null;  // stable prod_... id — gate on this
      version: string | null;    // plan display name, e.g. "Pro" — show this
      status: string | null;     // active / trialing / free / preview / none / …
    }
    
    const initial: AccessState = {
      loggedIn: false, loading: true, entitled: false,
      productId: null, version: null, status: null,
    };
    
    export function useAccessLevel(): AccessState {
      const [state, setState] = useState<AccessState>(initial);
    
      useEffect(() => {
        let cancelled = false;
        (async () => {
          try {
            const res = await fetch('/api/entitlement', { credentials: 'include' });
            if (res.status === 401) return;        // not signed in — leave defaults
            const { entitlement: ent } = await res.json();
            if (!res.ok || cancelled) return;
            setState({
              loggedIn: true,
              loading: false,
              entitled: !!ent?.entitled,
              productId: ent?.product_public_id ?? null,
              version: ent?.version ?? null,
              status: ent?.status ?? null,
            });
          } catch (err) {
            console.error('entitlement check failed', err);
          } finally {
            if (!cancelled) setState((s) => ({ ...s, loading: false }));
          }
        })();
        return () => { cancelled = true; };
      }, []);
    
      return state;
    }

    Step 5 — Share it through context

    Lift the hook into context so any component can read the plan, then gate UI off it.

    src/context/AccessProvider.tsx
    import { createContext, useContext, type ReactNode } from 'react';
    import { useAccessLevel, type AccessState } from '../hooks/useAccessLevel';
    
    const AccessContext = createContext<AccessState>({
      loggedIn: false, loading: true, entitled: false,
      productId: null, version: null, status: null,
    });
    
    export function AccessProvider({ children }: { children: ReactNode }) {
      return (
        <AccessContext.Provider value={useAccessLevel()}>
          {children}
        </AccessContext.Provider>
      );
    }
    
    export const useAccess = () => useContext(AccessContext);
    src/App.tsx
    import { AccessProvider, useAccess } from './context/AccessProvider';
    
    const PRO_PRODUCT_ID = 'prod_<your-pro-id>'; // stable prod_... id from the dashboard
    
    function Dashboard() {
      const { loading, loggedIn, entitled, productId, version } = useAccess();
      if (loading) return <p>Loading…</p>;
    
      if (!loggedIn) {
        return (
          <div>
            <h1>Welcome</h1>
            <p>Sign in with your UseThatApp account to see your plan.</p>
            {/* a plain link — navigating to the BFF starts the OAuth redirect */}
            <a href="/login">Sign in with UseThatApp</a>
          </div>
        );
      }
    
      // Gate on the stable product_public_id; show the mutable version name.
      if (entitled && productId === PRO_PRODUCT_ID) {
        return <h1>{version} Dashboard</h1>;
      }
      return (
        <div>
          <h1>Free Dashboard</h1>
          <a href="/login">Upgrade</a>
        </div>
      );
    }
    
    export default function App() {
      return (
        <AccessProvider>
          <Header />
          <Dashboard />
        </AccessProvider>
      );
    }
    
    function Header() {
      const { loading, loggedIn } = useAccess();
      if (loading) return null;
      return (
        <header>
          {loggedIn
            ? <a href="/logout">Sign out</a>
            : <a href="/login">Sign in with UseThatApp</a>}
        </header>
      );
    }

    Offer the upgrade: purchase links

    When the entitlement check says the user is not entitled (or is on the free tier), send them to hosted checkout with purchaseUrl(priceId). UseThatApp is the merchant of record — it processes the payment, remits tax, and handles refunds — and the buyer comes back to your app signed in with the entitlement already live. No webhooks, no polling: the next /api/entitlement fetch simply says entitled. Like the rest of the SDK, purchaseUrl() and manageUrl() run in the BFF (they're pure URL builders — no network call); expose them from an endpoint and let the SPA render plain links.

    server.js
    import { manageUrl, purchaseUrl } from 'usethatapp';
    
    const PRO_PRICE_ID = process.env.PRO_PRICE_ID; // prc_... — from getPrices() or the dashboard
    
    // Alongside /api/entitlement — URLs for the SPA's upgrade / manage links.
    app.get('/api/upgrade-url', (req, res) => {
      const session = peekSession(req);
      if (!session?.accessToken) return res.status(401).json({ error: 'not signed in' });
      res.json({
        upgrade: purchaseUrl(PRO_PRICE_ID, { next: `${APP_URL}/` }), // hosted checkout
        manage: manageUrl({ next: `${APP_URL}/` }),                  // subscription management
      });
    });
    src/components/UpgradeCta.tsx
    import { useEffect, useState } from 'react';
    import { useAccess } from '../context/AccessProvider';
    
    const PRO_PRODUCT_ID = 'prod_<your-pro-id>'; // stable prod_... id from the dashboard
    
    export function UpgradeCta() {
      const { loggedIn, entitled, productId } = useAccess();
      const [urls, setUrls] = useState<{ upgrade: string; manage: string } | null>(null);
    
      useEffect(() => {
        if (!loggedIn) return;
        fetch('/api/upgrade-url', { credentials: 'include' })
          .then((res) => (res.ok ? res.json() : null))
          .then(setUrls)
          .catch(() => setUrls(null));
      }, [loggedIn]);
    
      if (!loggedIn || !urls) return null;
      if (entitled && productId === PRO_PRODUCT_ID) {
        return <a href={urls.manage}>Manage subscription</a>;
      }
      // Not on Pro — hosted checkout. They return signed in, entitled.
      return <a href={urls.upgrade}>Upgrade to Pro</a>;
    }

    To render a live pricing page, call getPrices() in the BFF and never hardcode prices — each price carries a ready-made buy_url and the product_id that matches the entitlement’s product_public_id you gate on. See the Pricing API and Sell from your own website for the full flow (buy links resolve once your app passes its readiness checklist).

    Identity: the pairwise sub

    The BFF stores session.sub from completeLogin() — a pairwise pseudonymous identifier: stable for a user within your app, different in every other app, with no email or PII. Key your user records off sub, never an email. It's delivered at login, not in the entitlement response — so the SPA doesn't need it; keep it on the server.

    Sign out

    Sign-out is RP-initiated: the /logout route redirects to logoutUrl(...).

    ⚠ Reconcile on return — don't clear the session eagerly.

    Both outcomes — the user confirms, or chooses "Stay signed in" — return to your post-logout URL, so the redirect alone can't tell you which happened. That's why the BFF leaves the session intact: a confirmed logout revokes the token, so the next getEntitlement at /api/entitlement throws UtaTokenError (401) — the route drops the token then, and the SPA shows the signed-out view. If they stayed signed in, the token is still valid and they keep their session.

    Errors

    • 401 → UtaTokenError — the access token is missing, expired, or revoked. The BFF clears it and returns 401; the SPA shows the signed-out view (send the user back through /login).
    • 403 → UtaPermissionError — a valid token lacking the entitlements scope.
    • Every SDK error inherits from UtaError. See Error Handling.

    The BFF here is a plain Express app — the full route logic and patterns live in the Express guide. See also OpenID Connect and Gotchas.

    AI agents: this site is fully pre-rendered — fetch /llms.txt for an indexed summary of every page, or /llms-full.txt for the whole documentation as one Markdown file.