Vue
Sign users into your Vue 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 Vue 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 Vue 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
- 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 anhttpOnlycookie. - Vue SPA. The app calls
fetch('/api/entitlement', { credentials: 'include' })to read the plan and navigates to/loginto sign in. It never sees a token. - Vite proxy. In dev, Vite proxies
/login,/callback,/logoutand/apito the BFF so the browser talks to one origin.
Prerequisites
- Node.js 18+, server-side only (uses the global
fetchand Node'snode:crypto/node:fs— the SDK can never ship in your Vue bundle) - A Vite + Vue 3 app, plus a small Node/Express server alongside it
- An OAuth client from your app's Integration page:
UTA_CLIENT_IDandUTA_CLIENT_SECRET, withhttp://localhost:5173/callbackregistered as a redirect URI for development — see OpenID Connect
Step 1 — Install
npm install usethatapp express cookie-parser dotenvUTA_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. (This BFF is framework-agnostic — identical whether your front end is Vue or React.)
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).
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:3001',
'/login': 'http://localhost:3001',
'/callback': 'http://localhost:3001',
'/logout': 'http://localhost:3001',
},
},
});Step 4 — Read the plan in Vue
A composable fetches /api/entitlement once with credentials: 'include' and exposes reactive state. 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.
import { reactive, readonly, onMounted } from 'vue';
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 / …
}
export function useAccessLevel() {
const state = reactive<AccessState>({
loggedIn: false, loading: true, entitled: false,
productId: null, version: null, status: null,
});
onMounted(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) return;
state.loggedIn = true;
state.entitled = !!ent?.entitled;
state.productId = ent?.product_public_id ?? null;
state.version = ent?.version ?? null;
state.status = ent?.status ?? null;
} catch (err) {
console.error('entitlement check failed', err);
} finally {
state.loading = false;
}
});
return readonly(state);
}For app-wide access, call the composable once at the root and provide() it; children read it with inject() instead of re-fetching.
Step 5 — Gate the UI
<script setup lang="ts">
import { useAccessLevel } from './composables/useAccessLevel';
const PRO_PRODUCT_ID = 'prod_<your-pro-id>'; // stable prod_... id from the dashboard
const access = useAccessLevel();
</script>
<template>
<header>
<a v-if="!access.loading && access.loggedIn" href="/logout">Sign out</a>
<a v-else-if="!access.loading" href="/login">Sign in with UseThatApp</a>
</header>
<p v-if="access.loading">Loading…</p>
<!-- Signed out: a plain link to the BFF starts the OAuth redirect -->
<section v-else-if="!access.loggedIn">
<h1>Welcome</h1>
<p>Sign in with your UseThatApp account to see your plan.</p>
<a href="/login">Sign in with UseThatApp</a>
</section>
<!-- Gate on the stable product_public_id; show the mutable version name -->
<h1 v-else-if="access.entitled && access.productId === PRO_PRODUCT_ID">
{{ access.version }} Dashboard
</h1>
<section v-else>
<h1>Free Dashboard</h1>
<a href="/login">Upgrade</a>
</section>
</template>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.
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
});
});<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useAccessLevel } from '../composables/useAccessLevel';
const PRO_PRODUCT_ID = 'prod_<your-pro-id>'; // stable prod_... id from the dashboard
const access = useAccessLevel();
const urls = ref<{ upgrade: string; manage: string } | null>(null);
onMounted(async () => {
const res = await fetch('/api/upgrade-url', { credentials: 'include' });
if (res.ok) urls.value = await res.json();
});
</script>
<template>
<!-- Already on Pro: hosted subscription management. Otherwise: hosted checkout. -->
<a
v-if="urls && access.entitled && access.productId === PRO_PRODUCT_ID"
:href="urls.manage"
>Manage subscription</a>
<a v-else-if="urls && access.loggedIn" :href="urls.upgrade">Upgrade to Pro</a>
</template>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 theentitlementsscope. - 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.
