Headless / custom UI
Build your own login buttons — skip ConnectButton / ConnectModal. Same BYOK adapters and social methods; you call connect yourself.
@web5nexus/authmodal-core@0.3.2@web5nexus/authmodal-react@0.3.8@web5nexus/authmodal-bitcoin@0.1.2Choose an API
| Path | When to use | Entry |
|---|---|---|
| React hooks | Custom UI inside AuthModalProvider | useConnect() / useAccount() / useDisconnect() |
| Core client | Non-React or fully headless | createAuthModal() → client.connect() |
| Bitcoin client | BTC-first, no EVM modal | bitcoinAuth() → btc.connect() (Bitcoin Wallet) |
Modal theme / layout docs stay on Connect Modal. Adapter install + method matrix: Adapters.
Snippet lab
Loading headless lab…
React — custom buttons
Still wrap the tree in AuthModalProvider (adapters + transport). Omit ConnectButton.
import { AuthModalProvider, useConnect, useAccount, useDisconnect } from '@web5nexus/authmodal-react'
import { web3auth } from '@web5nexus/authmodal-web3auth'
import { magic } from '@web5nexus/authmodal-magic'
const adapters = [
web3auth({ clientId: process.env.NEXT_PUBLIC_W3A_CLIENT_ID! }),
magic({ apiKey: process.env.NEXT_PUBLIC_MAGIC_API_KEY! }),
]
function LoginRow() {
const { connect, isConnecting, error } = useConnect()
const { address, isConnected, adapterId } = useAccount()
const { disconnect } = useDisconnect()
if (isConnected) {
return (
<button type="button" onClick={() => void disconnect()}>
Disconnect {address} ({adapterId})
</button>
)
}
return (
<div>
<button
type="button"
disabled={isConnecting}
onClick={() => void connect('google', undefined, 'web3auth')}
>
Google · Web3Auth
</button>
<button
type="button"
disabled={isConnecting}
onClick={() => void connect('google', undefined, 'magic')}
>
Google · Magic
</button>
<button
type="button"
disabled={isConnecting}
onClick={() => void connect('email', 'user@example.com', 'web3auth')}
>
Email · Web3Auth
</button>
{error && <p>{error}</p>}
</div>
)
}
export default function App() {
return (
<AuthModalProvider
adapters={adapters}
preferredAdapters={['web3auth', 'magic']}
chains={[1, 8453]}
transport={{ mode: 'custom', chainId: 1, rpcUrl: 'https://rpc.ankr.com/eth' }}
>
<LoginRow />
</AuthModalProvider>
)
}useConnect signature
connect(
method: SocialMethod,
loginHint?: string, // email | phone | jwt
adapterId?: string, // force provider when several share a method
otpCode?: string, // after OtpRequiredError
): Promise<SocialSession>openModal is still available if you want a hybrid (custom CTA → modal).
Core — no React UI
import { createAuthModal, isOtpRequiredError } from '@web5nexus/authmodal-core'
import { web3auth } from '@web5nexus/authmodal-web3auth'
import { magic } from '@web5nexus/authmodal-magic'
const client = createAuthModal({
adapters: [
web3auth({ clientId: process.env.NEXT_PUBLIC_W3A_CLIENT_ID! }),
magic({ apiKey: process.env.NEXT_PUBLIC_MAGIC_API_KEY! }),
],
preferredAdapters: ['web3auth', 'magic'],
chains: [1, 8453],
transport: { mode: 'custom', chainId: 1, rpcUrl: 'https://rpc.ankr.com/eth' },
})
await client.init()
const session = await client.connect({
method: 'google',
adapterId: 'web3auth',
})
await client.setChain(8453)
await client.disconnect()ConnectOpts:
| Field | Role |
|---|---|
method | Social method id |
loginHint | Email / phone / JWT when required |
otpCode | Verification code after OTP challenge |
adapterId | Pin provider (web3auth, magic, …) |
chainId | Optional target chain for the session |
Subscribe with client.subscribe(setState) or read client.getState().
Email OTP flow
Some adapters send a code and throw OtpRequiredError before the session exists:
import { isOtpRequiredError } from '@web5nexus/authmodal-core'
try {
await client.connect({
method: 'email',
loginHint: email,
adapterId: 'web3auth',
})
} catch (e) {
if (isOtpRequiredError(e)) {
// show OTP input, then:
await client.connect({
method: 'email',
loginHint: e.email,
otpCode: codeFromUser,
adapterId: e.adapterId,
})
} else {
throw e
}
}React: same args on connect(method, loginHint, adapterId, otpCode). Challenge also appears on useAuthModal().state.otpChallenge when using the modal path.
Social methods — when loginHint is required
| Method | loginHint | Notes |
|---|---|---|
google, apple, discord, twitter, github, facebook, linkedin, twitch | No | Provider OAuth / redirect |
email | Email address | Often followed by OTP |
phone | E.164 phone | Web3Auth primarily |
jwt | JWT string | Web3Auth custom auth |
passkey | No | Web3Auth passkey |
Always pass adapterId when multiple adapters share the same method (e.g. Google on Web3Auth and Magic).
Per-provider headless
Enable only the methods you need via each factory’s socialMethods. Full catalog:
@web5nexus/authmodal-web3auth@0.2.5Key export: Yes · Bitcoin: Yes
@web5nexus/authmodal-magic@0.2.3Key export: No · Bitcoin: No
@web5nexus/authmodal-privy@0.1.3Key export: No · Bitcoin: No
@web5nexus/authmodal-particle@0.1.4Key export: No · Bitcoin: No
@web5nexus/authmodal-para@0.1.3Key export: No · Bitcoin: No
Web3Auth — @web5nexus/authmodal-web3auth
import { web3auth } from '@web5nexus/authmodal-web3auth'
web3auth({
clientId: process.env.NEXT_PUBLIC_W3A_CLIENT_ID!,
network: 'sapphire_devnet',
socialMethods: ['google', 'apple', 'email', 'discord', 'twitter', 'github'],
})
await client.connect({ method: 'google', adapterId: 'web3auth' })
await client.connect({
method: 'email',
loginHint: 'user@example.com',
adapterId: 'web3auth',
})Vite: import @web5nexus/authmodal-web3auth/polyfill first. Key export required for Bitcoin derive.
Magic — @web5nexus/authmodal-magic
import { magic } from '@web5nexus/authmodal-magic'
magic({
apiKey: process.env.NEXT_PUBLIC_MAGIC_API_KEY!,
socialMethods: ['email', 'google', 'apple'],
})
await client.connect({ method: 'email', loginHint: 'user@example.com', adapterId: 'magic' })
await client.connect({ method: 'google', adapterId: 'magic' })Privy — @web5nexus/authmodal-privy
import { privy } from '@web5nexus/authmodal-privy'
privy({
appId: process.env.NEXT_PUBLIC_PRIVY_APP_ID!,
socialMethods: ['email', 'google', 'apple', 'discord'],
})
await connect('google', undefined, 'privy')
await connect('email', 'user@example.com', 'privy')Particle — @web5nexus/authmodal-particle
import { particle } from '@web5nexus/authmodal-particle'
particle({
projectId: process.env.NEXT_PUBLIC_PARTICLE_PROJECT_ID!,
clientKey: process.env.NEXT_PUBLIC_PARTICLE_CLIENT_KEY!,
appId: process.env.NEXT_PUBLIC_PARTICLE_APP_ID!,
socialMethods: ['email', 'google', 'apple'],
})
await connect('google', undefined, 'particle')May need Particle WASM Vite plugin — see Auth Modal demo config.
Para — @web5nexus/authmodal-para
import { para } from '@web5nexus/authmodal-para'
para({
apiKey: process.env.NEXT_PUBLIC_PARA_API_KEY!,
socialMethods: ['email', 'google', 'apple'],
})
await connect('apple', undefined, 'para')Bitcoin headless (Web3Auth only)
import { bitcoinAuth } from '@web5nexus/authmodal-bitcoin'
const btc = bitcoinAuth({
clientId: process.env.NEXT_PUBLIC_W3A_CLIENT_ID!,
bitcoinNetwork: 'testnet',
addressTypes: ['taproot', 'segwit', 'legacy', 'nested-segwit'],
prefer: 'taproot',
})
await btc.init()
const session = await btc.connect({ method: 'google' })
// session.addresses.primary — bc1p… / tb1p…
await btc.connect({ method: 'email', loginHint: 'user@example.com' })React without the Bitcoin modal: BitcoinWalletProvider + useBitcoinWallet().connect({ method }). Details: Bitcoin Wallet.
Live reference
Auth Modal demo (Vite, port 5177):
- Core headless —
createAuthModal+ email/Google per adapter - Per-adapter panels — every social method for Web3Auth / Magic / Privy / Particle / Para
Docs labs stay snippet-only (Next does not bundle provider SDKs).
Next
- Marketing Hub (B2B) — App
pk_→ identify Users + Web5 MAW - Adapters — packages + method matrix
- Connect Modal — branded modal UI
- Playground — modal theme labs
- Get Started