@uninspired/auth-client
v2.1.1
Published
Auth client for Uninspired Studio Products
Readme
@uninspired/auth-client
Client SDK for integrating Uninspired Studio authentication, purchases, and checkout into product apps (e.g. Momentum, Unpinned).
Works in any JavaScript environment. Includes a React provider and hook for apps that use React.
Installation
bun add @uninspired/auth-client
# or
npm install @uninspired/auth-clientPeer dependencies
For React apps, also install:
bun add react react-domposthog-js is a dependency of this package and is initialized by TrackingProvider. Do not initialize posthog-js yourself — the shared tracking client owns PostHog init and the Meta Pixel lifecycle so consent is applied consistently.
What it provides
| Feature | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| Session management | Fetch the current user session (including anonymous sessions) |
| Anonymous sign-in | Automatically sign in visitors as anonymous users |
| Sign out | End the current session |
| Purchase data | Fetch Paddle transactions and Stripe/Paddle subscriptions |
| Access checks | isPurchased() and isSubscribed() helpers for gating features |
| Login URL builder | Generate a link to the accounts page with a post-login redirect back to your app |
| Checkout URL builder | Generate a link to the hosted checkout flow |
| Newsletter | Subscribe/unsubscribe from mailing lists |
| Tracking client | TrackingProvider owns PostHog (cookieless until analytics consent) and the Meta Pixel (only with marketing consent) |
| Consent API | useTrackingConsent() / getConsent() / setConsent() for your cookie banner |
| Analytics helpers | captureFeatureUsed, captureCheckoutStarted, registerProduct — delegate to the tracking client |
Authentication methods (magic link, email OTP, passkey) are handled on the accounts page — this client manages sessions and purchase data, not the login UI itself.
Quick start (React)
Wrap your app in TrackingProvider, then AuthProvider, and read session/purchase state from useAuth():
import {
AuthProvider,
TrackingProvider,
useAuth,
} from "@uninspired/auth-client";
function App() {
return (
<TrackingProvider
product="mixd"
posthog={{ apiKey, apiHost, uiHost }}
metaPixelId={metaPixelId} // optional; public pixel id
>
<AuthProvider
baseUrl="https://auth.uninspired.app"
frontendUrl="https://uninspired.app"
product="mixd"
>
<MyApp />
</AuthProvider>
</TrackingProvider>
);
}TrackingProvider initializes PostHog (cookieless until analytics consent) and loads the Meta Pixel only when marketing consent is granted. Without a TrackingProvider, analytics and Meta no-op.
Vanilla helpers (no React) are also exported and delegate to a PostHog instance you pass in:
import {
captureFeatureUsed,
captureCheckoutStarted,
registerProduct,
} from "@uninspired/auth-client";Consent (cookie banner)
Consent is stored in the shared uninspired_consent cookie (analytics / marketing) and is the single source of truth.
Drop-in banner
Mount CookieBanner from the separate entry so the main SDK bundle stays lean:
import { TrackingProvider, AuthProvider } from "@uninspired/auth-client";
import { CookieBanner } from "@uninspired/auth-client/cookie-banner";
<TrackingProvider product="mixd" posthog={...} metaPixelId={...}>
<AuthProvider baseUrl={apiUrl} frontendUrl={accountsUrl} product="mixd">
<CookieBanner />
<App />
</AuthProvider>
</TrackingProvider>Peers for the banner entry: react, react-dom, @subframe/core, @radix-ui/react-dialog.
Tailwind: scan the banner output (or source in the monorepo) so utility classes are generated, e.g. add node_modules/@uninspired/auth-client/dist/cookie-banner.js (or ../../packages/auth-client/src/**/*.{ts,tsx} locally) to your content array. Use the same Subframe token preset as @us-auth/components / your accounts theme.
Optional prop: privacyPolicyUrl (defaults to the Uninspired Studio privacy policy).
Headless API
Build your own UI with the consent hooks/store:
import { useTrackingConsent } from "@uninspired/auth-client";
const { pending, consent, setConsent } = useTrackingConsent();Imperative equivalents (usable outside React): getConsent(), isConsentPending(), setConsent(), subscribeConsent().
- Pending / denied analytics: PostHog captures cookielessly (pageviews, autocapture, custom events) with no browser storage and no
identify. - Pending (no decision yet): leftover PostHog/Meta cookies and matching
localStorage/sessionStoragekeys from before the banner are deleted on load (HttpOnly cookies cannot be cleared from JS). - Granted analytics: PostHog upgrades live to cookies +
identify(user.id)(no email). - Granted marketing: the Meta Pixel loads and fires
PageView/ViewContent/InitiateCheckout/Purchase.
Tracking (PostHog + Meta)
const tracking = useTracking();
tracking?.capture("feature opened", { feature: "x" }); // works in every consent mode
tracking?.trackViewContent({ contentId: "pro_123" }); // Meta, marketing-gated
tracking?.trackPurchase({ eventId, value, currency, contentIds }); // Meta, marketing-gateduseAuth() also exposes captureFeatureUsed, captureCheckoutStarted, and registerProduct, which delegate to the tracking client.
Feature flags
TrackingProvider wraps PostHog's React context around the same posthog-js instance it initializes. Do not mount a second PostHogProvider or call posthog.init() yourself.
import {
useFeatureFlagVariantKey,
useTracking,
getPostHog,
} from "@uninspired/auth-client";
const variant = useFeatureFlagVariantKey("my-flag");
const tracking = useTracking();
const flag = tracking?.getFeatureFlag("my-flag");
const ph = getPostHog();Feature flags load in every consent mode (cookieless included). identify stays consent-gated, so person-based targeting applies only after analytics consent and login.
Better Auth client (accounts login UI)
For apps that host the login UI (e.g. apps/frontend), use the shared Better Auth factory instead of duplicating plugin setup:
import { createBetterAuthClient } from "@uninspired/auth-client";
const authClient = createBetterAuthClient("https://auth.uninspired.app");
await authClient.emailOtp.sendVerificationOtp({ email, type: "sign-in" });
await authClient.changeEmail({ newEmail: email, callbackURL: "/auth" });Also exported: useEmailOtpLoginFlow, resendButtonLabel, and RESEND_COOLDOWN_SECONDS for OTP login screens.
Quick start (React, auth only)
import { AuthProvider, useAuth } from "@uninspired/auth-client";
function App() {
return (
<AuthProvider
baseUrl="https://auth.uninspired.app"
frontendUrl="https://uninspired.app"
>
<MyApp />
</AuthProvider>
);
}
function MyApp() {
const { isLoggedIn, session, isPurchased, getLoginUrl, signOut } = useAuth();
if (!isLoggedIn) {
return (
<a
href={getLoginUrl({
redirectUrl: window.location.href,
appName: "My App",
})}
>
Log in
</a>
);
}
const hasPro = isPurchased(["pri_01234567890"]);
return (
<div>
<p>Hello, {session?.user.email ?? "guest"}</p>
{hasPro ? <ProFeature /> : <UpgradePrompt />}
<button onClick={() => signOut()}>Sign out</button>
</div>
);
}AuthClient (vanilla / SSR)
Use AuthClient directly when you are not in a React tree, or in server-side rendering.
import { AuthClient } from "@uninspired/auth-client";
const client = new AuthClient(
"https://auth.uninspired.app", // API URL
"https://uninspired.app", // Accounts frontend URL
request.headers.get("cookie"), // optional: forward cookies for SSR
);
const session = await client.getSession();
const purchases = await client.getPurchases();Constructor
new AuthClient(apiUrl, frontendUrl, cookie?)| Parameter | Description |
| ------------- | ------------------------------------------------------------ |
| apiUrl | Auth API base URL (e.g. https://auth.uninspired.app) |
| frontendUrl | Accounts frontend URL (e.g. https://uninspired.app) |
| cookie | Optional cookie header string for server-side session lookup |
All API requests use credentials: "include" in the browser, so session cookies are sent automatically on the same root domain.
Methods
Session
const session = await client.getSession();
// Returns Session | null
await client.signInAnonymously();
// Creates an anonymous session (done automatically by AuthProvider)
await client.signOut(onSuccess?);
// Signs out the current userPurchases
const purchases = await client.getPurchases();
// Returns UserPurchases | nullUserPurchases contains:
{
paddle: {
customerId?: string;
subscriptions: Array<{ id, status, items: [{ priceId, productId }] }>;
transactions: Array<{ id, items: string[], adjustments: [...] }>;
};
stripe: Array<{ id, status, items: string[] }>;
}Newsletter subscribe/unsubscribe uses @uninspired/newsletter-client, not AuthClient.
URL builders
// Link to the accounts login page, redirect back after auth
const loginUrl = client.getLoginUrl({
redirectUrl: "https://myapp.uninspired.app/dashboard",
appName: "My App", // optional, shown on the login page
});
// Link to the hosted checkout flow
const checkoutUrl = client.getCheckoutUrl({
priceId: "pri_01234567890",
productId: "pro_01234567890",
discountCode: "SAVE20", // optional
successUrl: "https://myapp.uninspired.app/welcome", // optional
quantity: 1, // optional, defaults to 1
});Meta ad attribution
Meta tracking lives in the browser pixel, not on AuthClient. The pixel is loaded and fired by TrackingProvider / useTracking() only when the user granted marketing consent. There are no server-side CAPI helpers on AuthClient anymore.
const tracking = useTracking();
tracking?.trackViewContent({ contentId: "pro_123" }); // no-op without marketing consentAuthProvider and useAuth (React)
AuthProvider props
| Prop | Type | Description |
| ------------------ | ------------------------ | ----------------------------- |
| baseUrl | string | Auth API URL |
| frontendUrl | string | Accounts frontend URL |
| product | AnalyticsProduct? | Registered as a PostHog super property on all events |
| initialSession | Session \| null? | Pre-fetched session for SSR |
| initialPurchases | UserPurchases \| null? | Pre-fetched purchases for SSR |
| authClient | AuthClient? | Custom client instance |
| queryClient | QueryClient? | Custom TanStack Query client |
The provider automatically:
- Revalidates the session on mount via
get-session(with 1-minute stale time; SSRinitialSessionis shown immediately while revalidating) - Signs in anonymously only after revalidation confirms no session exists
- Fetches purchases when a session is available
useAuth() return value
| Property | Type | Description |
| --------------------- | ---------------------------------------------------- | ----------------------------------------------- |
| session | Session \| null | Current session |
| isLoggedIn | boolean | true if user is authenticated (not anonymous) |
| isSessionFetching | boolean | Session query loading state |
| refetchSession | () => Promise<void> | Re-fetch session |
| purchases | UserPurchases \| null | User's purchase data |
| isPurchasesFetching | boolean | Purchases query loading state |
| refetchPurchases | () => Promise<void> | Re-fetch purchases |
| signOut | (onSuccess?) => Promise<void> | Sign out |
| getLoginUrl | AuthClient["getLoginUrl"] | Build login URL |
| getCheckoutUrl | AuthClient["getCheckoutUrl"] | Build checkout URL |
| captureFeatureUsed | (featureName, extra?) => void | Delegates to the tracking client |
| captureCheckoutStarted | ({ plan, product? }) => void | Delegates to the tracking client |
| registerProduct | (product) => void | Delegates to the tracking client |
| isPurchased | (priceIds: string[]) => boolean | Check one-time purchase |
| isSubscribed | (priceIds: string[], productId: string) => boolean | Check active subscription |
Access checks
const { isPurchased, isSubscribed } = useAuth();
// One-time purchase: true if user bought any of these price IDs (and hasn't been refunded)
isPurchased(["pri_lifetime", "pri_bundle"]);
// Subscription: true if user has an active Paddle or Stripe subscription
isSubscribed(["pri_monthly", "pri_yearly"], "pro_product_id");Server-side rendering
For SSR frameworks, fetch session and purchases on the server and pass them as initial data:
// server
import { AuthClient } from "@uninspired/auth-client";
const client = new AuthClient(
process.env.AUTH_API_URL,
process.env.ACCOUNTS_FRONTEND_URL,
request.headers.get("cookie"),
);
const [session, purchases] = await Promise.all([
client.getSession(),
client.getPurchases(),
]);
// render
<AuthProvider
baseUrl={process.env.AUTH_API_URL}
frontendUrl={process.env.ACCOUNTS_FRONTEND_URL}
initialSession={session}
initialPurchases={purchases}
>
{children}
</AuthProvider>;Forward the request's Cookie header to the constructor so the API can resolve the session server-side.
Cross-subdomain cookies
Sessions use cross-subdomain cookies scoped to the Uninspired Studio root domain (e.g. .uninspired.app). Your product app must be hosted on a subdomain of the same root domain for cookies to be shared automatically.
Make sure your app's origin is listed in the auth API's FRONTEND_URLS configuration.
Types
The package re-exports shared types:
import type { Session, UserPurchases } from "@uninspired/auth-client";Building from source
This package is part of the US Auth monorepo.
Workspace consumers (monorepo)
Entry points resolve to TypeScript source (./src/index.ts) so Vite and moduleResolution: "bundler" can import the package without a pre-build:
{
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" }
}No bun run build is required when developing apps inside this repo.
Publishing (npm)
Published tarballs ship only compiled output in dist/. npm ignores publishConfig manifest overrides, so prepack/postpack apply them via scripts/apply-publish-config.mjs (build runs in prepack too):
cd packages/auth-client
npm publishProduct apps outside the monorepo install the compiled ESM + .d.ts from dist/.
Output goes to dist/.
Environment URLs
| Environment | API | Accounts frontend |
| ----------- | --------------------------------- | ---------------------------- |
| Production | https://auth.uninspired.app | https://uninspired.app |
| Development | https://auth.dev.uninspired.app | https://dev.uninspired.app |
| Local | http://localhost:3000 | http://localhost:3001 |
