TypeScript SDK for Superwall in the browser, on the server, and inside React.
| Package | What | Runs in |
|---|---|---|
@superwall/paywalls-js |
Headless core: No DOM refs at module load. | Node, Bun, edge, workers, SSR, browser |
@superwall/paywalls-js/browser |
createBrowserPresenter createBrowserStorage |
Browser only |
@superwall/paywalls-react |
<SuperwallProvider>, useSuperwall, useSignal, useUser, usePlacement, useSuperwallEvent, useDelegate. React 19. |
Browser (SSR-safe imports) |
@superwall/example-browser |
Runnable Bun + vanilla TS demo. | Browser |
ESM-only.
# In your app:
bun add @superwall/paywalls-js # headless + browser subpath
bun add @superwall/paywalls-react react # if you're using ReactWorkspace dev install:
bun install # from the workspace rootimport { createSuperwall } from "@superwall/paywalls-js";
const sw = createSuperwall({ apiKey: "pk_web_…" });
// Optional — block until config + identity hydration land. Most methods
// internally await sw.ready so you don't need to.
await sw.ready;
await sw.user.identify("user_42");
// Listen on lifecycle events (typed CustomEvent)
const ac = new AbortController();
sw.events.addEventListener("subscriptionStatus_didChange", () => {
console.log("status changed →", sw.subscriptionStatus.value);
}, { signal: ac.signal });
// Show a paywall (or skip if entitled)
const result = await sw.register({
placement: "checkout",
feature: () => unlock(), // runs on entitled / purchased / non-gated skip
});
if (result.type === "presented" && result.result.type === "purchased") {
console.log("Bought:", result.result.productId);
}import { createSuperwall, user, register, events } from "@superwall/paywalls-js";
createSuperwall({ apiKey: "pk_web_…" }); // first call registers the default
await user.identify("user_42");
const r = await register({ placement: "checkout" });
events.addEventListener("paywall_close", (e) => console.log(e.detail));If you only import { user }, the rest is dead code → ESM bundlers drop it.
import { SuperwallProvider, useUser, usePlacement } from "@superwall/paywalls-react";
function App() {
return (
<SuperwallProvider apiKey="pk_web_…">
<Home />
</SuperwallProvider>
);
}
function Home() {
const { id, isLoggedIn, identify } = useUser();
const { register, state } = usePlacement({
onPresent: (info) => console.log("opened:", info.identifier),
onDismiss: (_info, result) => console.log("dismissed:", result),
});
return (
<>
<button onClick={() => identify("user_42")}>Sign in</button>
<button onClick={() => register({ placement: "checkout" })}>Upgrade</button>
<p>userId: {id || "anonymous"} • paywall: {state.type}</p>
</>
);
}By default every method internally awaits sw.ready, so you don't need a Suspense boundary. Use it only if you want a fallback while initial config + enrichment land:
import { use, Suspense } from "react";
import { useSuperwall } from "@superwall/paywalls-react";
function ConfigGate({ children }: { children: React.ReactNode }) {
use(useSuperwall().ready); // suspends until ready
return <>{children}</>;
}
<SuperwallProvider apiKey="pk_web_…">
<Suspense fallback={<Loading />}>
<ConfigGate><Home /></ConfigGate>
</Suspense>
</SuperwallProvider>SSR caveat.
use(sw.ready)will suspend during server render — render eagerly server-side. The Provider hydrates with seeded identity from cookies; client-side re-render is automatic if the resolved identity differs.
Default — built-in Stripe checkout. A standard Superwall web paywall runs
Stripe checkout inside the paywall iframe; the SDK + backend complete it and
flip sw.subscriptionStatus to ACTIVE automatically (via the default
purchase controller). You don't run checkout or set status yourself — just
read sw.subscriptionStatus / gate on entitlements.
Module augmentation closes the shape — no generics on call sites:
// types.d.ts
declare module "@superwall/paywalls-js" {
interface UserAttributes {
email?: string;
plan?: "free" | "pro" | "enterprise";
}
interface PlacementParams {
screen?: string;
referrer?: string;
}
}
sw.user.setAttributes({ email: "a@b.co", plan: "pro" }); // ✅ typed
sw.user.setAttributes({ email: "a@b.co", plan: "premium" }); // ❌ TS errorImplement the PaywallPresenter interface:
import type { PaywallPresenter } from "@superwall/paywalls-js";
const myPresenter: PaywallPresenter = {
async present(info, ctx) {
// ... show your UI; return when the user dismisses
return { type: "purchased", productId: "pro_yearly" };
},
dismiss() { /* tear down */ },
};
// Pass per-call via register(), not at createSuperwall time:
await sw.register({ placement: "checkout", presenter: myPresenter });sw.setDelegate({
onEvent(name, detail) { analytics.track(name, detail); },
onSubscriptionStatusChange(from, to) { /* … */ },
onPaywallDidPresent(info) { /* … */ },
});In React, prefer the hook (auto-cleanup):
useDelegate({ onEvent: (name) => analytics.track(name) });import { createSuperwall } from "@superwall/paywalls-js";
// No /browser import; no presenter; pre-seed identity from cookies.
const sw = createSuperwall({
apiKey,
identity: {
aliasId: req.cookies["_sw_alias_id"],
appUserId: req.cookies["_sw_user_id"],
},
});
// `register` requires a presenter — use getPresentationResult instead for
// pure server-side decisions:
const decision = await sw.placements.getPresentationResult("checkout");
// → { type: "paywallNotAvailable" } in v0 alpha (config processing pending — see MISSING.md)bun install # workspace install (Bun workspaces)
bun run typecheck # turbo run typecheck (all packages)
bun run test # turbo run test (all packages)
bun run build # turbo run build (no-op in v0; ESM source ships as-is)
# Run the example (vanilla TS)
bun --filter @superwall/example-browser dev
# → http://localhost:3000Superwall-Web/
package.json # workspace root, Bun + Turbo
turbo.json
tsconfig.base.json # strict TS + Effect language service plugin
packages/
paywalls-js/ # headless core + /browser subpath
src/ # public modules
src/internal/ # Effect-only (not exported from barrel)
src/browser/ # browser presenter + storage
paywalls-react/ # React 19 bindings
example/
example-browser/ # runnable Bun + TS demo
paywalls-js uses vitest (bun run test calls vitest run). Browser-package tests use @happy-dom/global-registrator registered via bunfig.toml preload. React tests use @testing-library/react.
326 tests across the workspace.
The effect-language-service TS plugin is wired into tsconfig.base.json. In VS Code: F1 → "TypeScript: Select TypeScript Version" → "Use Workspace Version" to get Effect-specific diagnostics.