No React, no Vue, no framework at all — just a script tag in an HTML page. This is the simplest possible integration, and it doesn't need a DOM-ready wrapper, a bundler, or any special setup beyond one detail: use type="module".
<!doctype html>
<html>
<body>
<input id="email-input" placeholder="email" />
<button id="send-btn">Send code</button>
<input id="code-input" placeholder="code" />
<button id="verify-btn">Verify</button>
<script type="module">
import { Threshold1 } from "@threshold1/auth";
// Module top level — these elements already exist by the time this
// line runs. No DOMContentLoaded wrapper needed; see below for why.
const emailInput = document.getElementById("email-input");
const codeInput = document.getElementById("code-input");
// Module scope — constructed once, when this script first runs.
const auth = new Threshold1({ apiKey: "th_test_..." });
document.getElementById("send-btn").addEventListener("click", async () => {
await auth.sendOtp(emailInput.value);
});
document.getElementById("verify-btn").addEventListener("click", async () => {
const user = await auth.verifyOtp(emailInput.value, codeInput.value);
console.log(user);
});
</script>
</body>
</html>
This exact pattern — live-verified end to end against the real hosted API: real sendOtp()/verifyOtp() round trip triggered from real button clicks, real email, real code, a real session returned, zero console warnings or errors at any point.
DOMContentLoaded wrapper is neededThe reason isn't "it happens to work" — it's a specific, spec-guaranteed behavior of <script type="module">. Per the HTML spec, module scripts are deferred automatically: the browser parses the rest of the document before executing a module script, exactly as if it had a defer attribute, whether the script tag lives in <head> or at the end of <body>. A plain <script> (no type="module") does not get this treatment — it executes immediately at the point it's encountered, which is why the old convention was either to put it at the very end of <body> or wrap everything in a DOMContentLoaded listener.
type="module" gives you that deferral for free. document.getElementById(...) calls sitting directly at the top level of the module — not inside any wrapper — are guaranteed to run after the DOM they're querying already exists. This isn't a threshold1-specific trick; it's a real, load-bearing property of ES modules in the browser, and it's why the pattern above has no ceremony around DOM readiness at all.
The example above imports @threshold1/auth as a bare specifier, which needs either a bundler (Vite, esbuild, or similar — install the package normally and this works with zero extra configuration) or, in a truly build-free setup, an import map or a direct CDN URL in the import statement instead. This page's DOM-timing guidance applies identically either way — module resolution and DOM-ready timing are unrelated concerns.