@threshold1/auth is framework-agnostic — nothing about the SDK itself is Next.js-specific. What is specific to Next.js's App Router is timing: WebAuthn and localStorage are browser-only, and App Router renders on the server before it ever touches a browser. This page covers the one pattern that gets that right, and the one mistake that gets it wrong without telling you.
Put your Threshold1 instance in a Client Component, constructed once at module scope, and only call its methods from inside useEffect or an event handler — never directly in the render body.
"use client";
import { useEffect, useState } from "react";
import { Threshold1 } from "@threshold1/auth";
// Module scope — constructed once, not on every render.
const auth = new Threshold1({
apiKey: "th_test_...",
auth: { fallback: "smart" },
});
export default function LoginForm() {
const [email, setEmail] = useState("");
// Runs once after mount, in the browser only — the right place for
// anything that needs to happen automatically on page load, like
// picking up a magic-link redirect.
useEffect(() => {
auth.resumeSession().then((user) => {
if (user) {
// already logged in via a magic-link click
}
});
}, []);
// Triggered by a real user action — the right place for register()/login().
async function handleSubmit() {
const result = await auth.login(email);
if (result.status === "success") {
// logged in
}
}
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">Log in</button>
</form>
);
}
This exact pattern — live-verified end to end against the real hosted API from a real Next.js 16 App Router route: real sendOtp()/verifyOtp() round trip, real email, real code, real session, zero console warnings at any point.
Call login()/register() directly in a component's render body — outside useEffect and outside an event handler — and nothing crashes. That's what makes it dangerous. typeof window === "undefined" is true during any server-side evaluation, and the SDK treats that as "passkey isn't supported here" and quietly moves on to the next method in the cascade. No error. No warning. No exception to catch. Your register() call still resolves successfully — it just never tried passkey at all, and you'd have no way of knowing from the result alone.
A developer who calls login(email) straight in the render body, watches it work in the browser (magic link or OTP wins the fallback silently), and ships it, has shipped an integration where passkey — threshold1's flagship, fastest, most secure method — never actually gets offered to a single real user. Nothing about the running app tells you this happened. The only way to catch it is to know to look for it, which is exactly what this page is for.
Keep every auth.*() call inside useEffect (for anything that should run automatically) or an event handler (for anything triggered by the user) — never in the function body of the component itself.
"use client" does not mean "this code never runs on the server." It means "this code also ships to the browser and hydrates there." Next.js still server-renders Client Components to produce the initial HTML — so the module-scope const auth = new Threshold1(...) line genuinely executes once during that server pass, and then again in the browser during hydration.
This is proven harmless, not just assumed: live-verified with a real Next.js dev server, the constructor runs cleanly on the server (typeof window is "undefined" there, confirmed in the server console) and cleanly again in the browser (typeof window is "object" there) — construction itself never touches window, localStorage, or any WebAuthn API, so neither pass errors or warns. The constructor is just building a plain config object in memory at this point; nothing browser-dependent happens until you actually call one of its methods. That's exactly why the danger described above is specifically about calling methods outside useEffect/handlers, not about the instantiation line itself.
sendOtp()/verifyOtp()), zero dashboard config required.login() call runs through.