UseThatApp Logo
    JavaScript/Next.js

    Next.js

    Sign users into your Next.js app with their UseThatApp account and gate features on their live plan, over standard OAuth 2.0 / OpenID Connect. These routes are one end of the full selling loop: a buyer who purchases on your website through a UseThatApp buy link lands back here signed in, entitlement already live — and the same routes serve marketplace launches. Next.js already has a server, so the SDK lives in your Route Handlers — no separate backend needed.

    ⚠ The SDK is server-only.

    The usethatapp SDK is a confidential client: it holds your client secret and validates ID tokens. Import it only in server code — Route Handlers (app/api/.../route.ts), Server Components, or server actions. Never import it into a Client Component ("use client"), or the secret ends up in your browser bundle. Client Components read the plan by fetching a Route Handler. It also needs the Node.js runtime (node:crypto): it cannot run in Edge routes (export const runtime = "edge") or in middleware.ts, which is Edge-only — do entitlement checks in a Route Handler and pin export const runtime = "nodejs".

    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. Route Handlers run the SDK. /api/login (redirect to UseThatApp), /api/callback (exchange the code for tokens), /api/logout, and /api/entitlement (read the live plan). Tokens live in an httpOnly cookie session via cookies().
    2. Server Components read the plan directly (await the session, call getEntitlement). Client Components call /api/entitlement over fetch when they need the live tier.

    Prerequisites

    • Node.js 18+ and a Next.js 13+ project using the App Router
    • An OAuth client from your app's Integration page: UTA_CLIENT_ID and UTA_CLIENT_SECRET, with http://localhost:3000/api/callback registered as a redirect URI for development — see OpenID Connect

    Step 1 — Install and configure

    terminal
    npm install usethatapp iron-session
    .env.local (server-side only)
    UTA_CLIENT_ID=...            # required
    UTA_CLIENT_SECRET=...        # required (server-side only)
    UTA_REDIRECT_URI=http://localhost:3000/api/callback
    SESSION_SECRET=...           # 32+ chars, for the encrypted session cookie
    # optional — default to production:
    UTA_ISSUER=https://www.usethatapp.com/o
    UTA_API_URL=https://www.usethatapp.com
    UTA_SCOPES="openid entitlements"

    A small helper wraps the encrypted session cookie. We store only the OAuth tokens, the user's sub, and the in-flight flowState.

    lib/session.ts
    import { getIronSession } from "iron-session";
    import { cookies } from "next/headers";
    import type { UtaFlowState } from "usethatapp";
    
    export interface UtaSessionData {
      flow?: UtaFlowState;
      sub?: string;
      accessToken?: string;
      idToken?: string;
    }
    
    export function getSession() {
      return getIronSession<UtaSessionData>(cookies(), {
        password: process.env.SESSION_SECRET!, // 32+ chars
        cookieName: "uta-session",
        cookieOptions: { secure: true, sameSite: "lax", httpOnly: true },
      });
    }

    Step 2 — Login and callback routes

    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.

    app/api/login/route.ts
    import { beginLogin } from "usethatapp";
    import { NextResponse } from "next/server";
    import { getSession } from "@/lib/session";
    
    // The SDK needs Node (node:crypto) — pin it so a later "edge" default
    // or refactor can't silently move this route off the Node runtime.
    export const runtime = "nodejs";
    
    export async function GET() {
      const session = await getSession();
      const { authorizationUrl, flowState } = await beginLogin();
      session.flow = flowState;          // stash across the redirect
      await session.save();
      return NextResponse.redirect(authorizationUrl);
    }
    app/api/callback/route.ts
    import { completeLogin, UtaError } from "usethatapp";
    import { NextResponse } from "next/server";
    import { getSession } from "@/lib/session";
    
    export async function GET(request: Request) {
      const params = new URL(request.url).searchParams;
      const session = await getSession();
      const home = new URL("/", request.url);
    
      // On cancel/deny, OAuth redirects back with ?error=... and no code.
      if (params.get("error")) {
        session.flow = undefined;
        await session.save();
        return NextResponse.redirect(home);
      }
    
      try {
        const s = await completeLogin({
          code: params.get("code")!,
          state: params.get("state")!,
          flowState: session.flow!,
        });
        session.flow = undefined;
        session.sub = s.sub;             // pairwise, per-app id — your user key
        session.accessToken = s.access_token;
        session.idToken = s.id_token;
        await session.save();
        return NextResponse.redirect(home);
      } catch (e) {
        if (e instanceof UtaError) return NextResponse.redirect(home); // back, not signed in
        throw e;
      }
    }

    Step 3 — Entitlement and logout routes

    getEntitlement(accessToken) is always authoritative — a canceled license stops being entitled immediately. On UtaTokenError (401), drop the token and report signed-out; the client falls back to the signed-out view.

    app/api/entitlement/route.ts
    import { getEntitlement, UtaError, UtaTokenError } from "usethatapp";
    import { NextResponse } from "next/server";
    import { getSession } from "@/lib/session";
    
    export async function GET() {
      const session = await getSession();
      if (!session.accessToken) {
        return NextResponse.json({ error: "not signed in" }, { status: 401 });
      }
      try {
        const entitlement = await getEntitlement(session.accessToken);
        return NextResponse.json({ sub: session.sub, entitlement });
      } catch (e) {
        if (e instanceof UtaTokenError) {
          // Token revoked/expired (e.g. signed out of UseThatApp). Reconcile.
          session.destroy();
          return NextResponse.json({ error: "session ended" }, { status: 401 });
        }
        if (e instanceof UtaError) {
          return NextResponse.json({ error: e.message }, { status: 502 });
        }
        throw e;
      }
    }
    app/api/logout/route.ts
    import { logoutUrl } from "usethatapp";
    import { NextResponse } from "next/server";
    import { getSession } from "@/lib/session";
    
    export async function GET(request: Request) {
      // Don't clear the session yet — the user may choose "Stay signed in". A real
      // logout revokes the token, so the next /api/entitlement 401s and reconciles.
      const session = await getSession();
      const target = await logoutUrl({
        idToken: session.idToken,
        postLogoutRedirectUri: new URL("/", request.url).toString(),
      });
      return NextResponse.redirect(target);
    }

    Step 4 — Gate from a Server Component

    Server Components run on the server, so they read the plan directly — no client fetch needed. Gate logic on the stable product_public_id; show the mutable version (plan name) to the user.

    app/page.tsx
    import { getEntitlement } from "usethatapp";
    import { getSession } from "@/lib/session";
    
    const PRO_PRODUCT_ID = "prod_<your-pro-id>"; // stable prod_... id from the dashboard
    
    export default async function Page() {
      const session = await getSession();
    
      if (!session.accessToken) {
        return (
          <main>
            <h1>Welcome</h1>
            <p>Sign in with your UseThatApp account to see your plan.</p>
            <a href="/api/login">Sign in with UseThatApp</a>
          </main>
        );
      }
    
      const ent = await getEntitlement(session.accessToken);
      const isPro = ent.entitled && ent.product_public_id === PRO_PRODUCT_ID;
    
      return (
        <main>
          <h1>{isPro ? `${ent.version} Dashboard` : "Free Dashboard"}</h1>
          {!isPro && <a href="/api/login">Upgrade</a>}
          <a href="/api/logout">Sign out</a>
        </main>
      );
    }

    Step 5 — Live tier in a Client Component

    When a Client Component needs the live tier (e.g. after an upgrade mid-session), fetch /api/entitlement. A 401 means signed-out — show the sign-in link.

    app/Tier.tsx
    "use client";
    
    import { useEffect, useState } from "react";
    
    const PRO_PRODUCT_ID = "prod_<your-pro-id>";
    
    export default function Tier() {
      const [state, setState] = useState<{ loading: boolean; pro: boolean; version: string | null }>(
        { loading: true, pro: false, version: null },
      );
    
      useEffect(() => {
        fetch("/api/entitlement", { credentials: "include" })
          .then(async (res) => {
            if (res.status === 401) return setState({ loading: false, pro: false, version: null });
            const { entitlement: ent } = await res.json();
            setState({
              loading: false,
              pro: ent.entitled && ent.product_public_id === PRO_PRODUCT_ID,
              version: ent.version ?? null,
            });
          })
          .catch(() => setState((s) => ({ ...s, loading: false })));
      }, []);
    
      if (state.loading) return <p>Loading…</p>;
      if (!state.version) return <a href="/api/login">Sign in with UseThatApp</a>;
      return <h1>{state.pro ? `${state.version} Dashboard` : "Free Dashboard"}</h1>;
    }

    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 getEntitlement simply says entitled. A server-only Route Handler builds the URL and redirects — point the "Upgrade" links from Steps 4 and 5 at /api/upgrade. For already-entitled users, manageUrl() links to their hosted subscription-management page.

    app/api/upgrade/route.ts
    import { getEntitlement, manageUrl, purchaseUrl } from "usethatapp";
    import { NextResponse } from "next/server";
    import { getSession } from "@/lib/session";
    
    const PRO_PRODUCT_ID = "prod_<your-pro-id>"; // stable prod_... id from the dashboard
    const PRO_PRICE_ID = "<your-pro-price-id>";     // prc_... — from getPrices() or the dashboard
    
    export async function GET(request: Request) {
      const session = await getSession();
      const returnTo = new URL("/", request.url).toString();
    
      if (session.accessToken) {
        const ent = await getEntitlement(session.accessToken);
        if (ent.entitled && ent.product_public_id === PRO_PRODUCT_ID) {
          // Already on Pro — hosted subscription management instead.
          return NextResponse.redirect(manageUrl({ next: returnTo }));
        }
      }
      // Not on Pro — hosted checkout. They return signed in, entitled.
      return NextResponse.redirect(purchaseUrl(PRO_PRICE_ID, { next: returnTo }));
    }

    Both helpers are pure URL builders — no network call. To render a live pricing page, call getPrices() in a Server Component 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

    session.sub from completeLogin() is 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.

    Sign out

    ⚠ 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 /api/logout leaves the session intact: a confirmed logout revokes the token, so the next getEntitlement throws UtaTokenError (401) — /api/entitlement destroys the session then. 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. Send the user back through /api/login.
    • 403 → UtaPermissionError — a valid token lacking the entitlements scope.
    • Every SDK error inherits from UtaError. See Error Handling.

    For the underlying OAuth flow, see OpenID Connect. The same route logic in a plain server lives in the Express guide. Also review 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.