threshold1

Plain JS

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".

The pattern

<!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.

Why no DOMContentLoaded wrapper is needed

The 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.

Module resolution

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.

What's next

  • Node — the separate, narrower question of what actually works when there's no browser at all.
  • Quickstart — the full real flow this pattern is built from.