Nuxt
Sign users into your Nuxt 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. Nuxt ships a server (Nitro), so the SDK lives in server routes — your Vue app just calls them.
⚠ 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 Nitro server code — server/api/* routes and server/utils/*. Never import it into a Vue component or anything that ships to the browser, or the secret ends up in your bundle.
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
- Nitro server routes run the SDK.
server/api/login.get.ts(redirect to UseThatApp),server/api/callback.get.ts(exchange the code),server/api/logout.get.ts, andserver/api/entitlement.get.ts(read the live plan). Tokens live in an encrypted session viauseSession. - The Vue app reads the plan with
useFetch('/api/entitlement')and navigates to/api/loginto sign in. It never sees a token.
Prerequisites
- Node.js 18+ and a Nuxt 3 project
- An OAuth client from your app's Integration page:
UTA_CLIENT_IDandUTA_CLIENT_SECRET, withhttp://localhost:3000/api/callbackregistered as a redirect URI for development — see OpenID Connect
Step 1 — Install and configure
npm install usethatappUTA_CLIENT_ID=... # required
UTA_CLIENT_SECRET=... # required (server-side only)
UTA_REDIRECT_URI=http://localhost:3000/api/callback
NUXT_SESSION_PASSWORD=... # 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"Nuxt reads UTA_* straight from the environment. A tiny server util wraps the session so every route shares the same shape.
import type { H3Event } from "h3";
import type { UtaFlowState } from "usethatapp";
export interface UtaSessionData {
flow?: UtaFlowState;
sub?: string;
accessToken?: string;
idToken?: string;
}
export function utaSession(event: H3Event) {
return useSession<UtaSessionData>(event, {
password: process.env.NUXT_SESSION_PASSWORD!, // 32+ chars
name: "uta-session",
});
}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.
import { beginLogin } from "usethatapp";
export default defineEventHandler(async (event) => {
const session = await utaSession(event);
const { authorizationUrl, flowState } = await beginLogin();
await session.update({ flow: flowState }); // stash across the redirect
return sendRedirect(event, authorizationUrl);
});import { completeLogin, UtaError } from "usethatapp";
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const session = await utaSession(event);
// On cancel/deny, OAuth redirects back with ?error=... and no code.
if (query.error) {
await session.update({ flow: undefined });
return sendRedirect(event, "/");
}
try {
const s = await completeLogin({
code: String(query.code),
state: String(query.state),
flowState: session.data.flow!,
});
await session.update({
flow: undefined,
sub: s.sub, // pairwise, per-app id — your user key
accessToken: s.access_token,
idToken: s.id_token,
});
return sendRedirect(event, "/");
} catch (e) {
if (e instanceof UtaError) return sendRedirect(event, "/"); // 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), clear the session and report signed-out; the app falls back to the signed-out view.
import { getEntitlement, UtaError, UtaTokenError } from "usethatapp";
export default defineEventHandler(async (event) => {
const session = await utaSession(event);
const token = session.data.accessToken;
if (!token) {
throw createError({ statusCode: 401, statusMessage: "not signed in" });
}
try {
const entitlement = await getEntitlement(token);
return { sub: session.data.sub, entitlement };
} catch (e) {
if (e instanceof UtaTokenError) {
// Token revoked/expired (e.g. signed out of UseThatApp). Reconcile.
await session.clear();
throw createError({ statusCode: 401, statusMessage: "session ended" });
}
if (e instanceof UtaError) {
throw createError({ statusCode: 502, statusMessage: e.message });
}
throw e;
}
});import { logoutUrl } from "usethatapp";
export default defineEventHandler(async (event) => {
// 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 utaSession(event);
const target = await logoutUrl({
idToken: session.data.idToken,
postLogoutRedirectUri: getRequestURL(event).origin + "/",
});
return sendRedirect(event, target);
});Step 4 — Read the plan in Vue
Use useFetch to read /api/entitlement. 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.
<script setup lang="ts">
const PRO_PRODUCT_ID = "prod_<your-pro-id>"; // stable prod_... id from the dashboard
interface Entitlement {
readonly entitled: boolean;
readonly version: string | null;
readonly product_id: string | null;
readonly product_public_id: string | null;
readonly status: string;
readonly is_free: boolean;
readonly period_end: string | null;
readonly raw: Record<string, unknown>;
}
// 401 (signed out) lands in 'error'; the entitlement lands in 'data'.
const { data, error, pending } = await useFetch<{ entitlement: Entitlement }>(
"/api/entitlement",
{ credentials: "include" },
);
const ent = computed(() => data.value?.entitlement);
const isPro = computed(() => !!ent.value?.entitled && ent.value?.product_public_id === PRO_PRODUCT_ID);
const signedIn = computed(() => !!ent.value);
</script>
<template>
<header>
<a v-if="signedIn" href="/api/logout">Sign out</a>
<a v-else href="/api/login">Sign in with UseThatApp</a>
</header>
<p v-if="pending">Loading…</p>
<!-- Signed out: a plain link to the server route starts the OAuth redirect -->
<section v-else-if="!signedIn">
<h1>Welcome</h1>
<p>Sign in with your UseThatApp account to see your plan.</p>
<a href="/api/login">Sign in with UseThatApp</a>
</section>
<!-- Gate on the stable product_public_id; show the mutable version name -->
<h1 v-else-if="isPro">{{ ent?.version }} Dashboard</h1>
<section v-else>
<h1>Free Dashboard</h1>
<a href="/api/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 getEntitlement simply says entitled. A Nitro server route builds the URL and redirects — point the "Upgrade" link in Step 4 at /api/upgrade. For already-entitled users, manageUrl() links to their hosted subscription-management page.
import { getEntitlement, manageUrl, purchaseUrl } from "usethatapp";
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 default defineEventHandler(async (event) => {
const session = await utaSession(event);
const returnTo = getRequestURL(event).origin + "/";
const token = session.data.accessToken;
if (token) {
const ent = await getEntitlement(token);
if (ent.entitled && ent.product_public_id === PRO_PRODUCT_ID) {
// Already on Pro — hosted subscription management instead.
return sendRedirect(event, manageUrl({ next: returnTo }));
}
}
// Not on Pro — hosted checkout. They return signed in, entitled.
return sendRedirect(event, 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 route 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.data.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 — keep it on the server.
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 clears the session then, and the app 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. Send the user back through/api/login. - 403 →
UtaPermissionError— a valid token lacking theentitlementsscope. - 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.
