| name | client |
| description | Set up the siwnClient plugin for Better Auth client, configure NEAR wallet connection via NearConnect, use authClient.near actions for sign-in, profile lookup, account management, delegate action building with TransactionBuilder, and relay submission. Load when implementing NEAR wallet sign-in on the client, using authClient.near.* methods, or building delegate actions for gasless relay.
|
| metadata | {"type":"core","library":"better-near-auth","library_version":"1.7.3"} |
| sources | ["elliotBraem/better-near-auth:src/client.ts","elliotBraem/better-near-auth:src/types.ts","elliotBraem/better-near-auth:README.md","elliotBraem/better-near-auth:LLM.txt"] |
Better-Near-Auth — Client Integration
Client-side plugin for NEAR wallet authentication and gasless relay. Connects to NEAR wallets via NearConnect, provides authClient.near.* actions for sign-in, account management, profile lookup, and delegate action building.
SSR Behavior
siwnClient() is SSR-safe. Wallet resources (NearConnector, Near, event listeners) are lazily initialized on first client-side access — they do not run at construction time. On the server:
getAccountId() returns null (no session restore yet)
getState() returns null
isWalletConnected() returns false
buildSignedDelegateAction, signWithWallet, ensureConnected throw "Wallet not initialized — this operation requires a browser environment"
near.client getter throws on access
- HTTP-only methods (
nonce, verify, view, relayTransaction, etc.) work normally via $fetch
Setup
Simple app (module singleton)
import { createAuthClient } from "better-auth/client";
import { siwnClient } from "better-near-auth/client";
export const authClient = createAuthClient({
plugins: [
siwnClient({
recipient: "myapp.com",
networkId: "mainnet",
}),
],
});
SSR app (router context)
For TanStack Router with server rendering, create the auth client once in your router setup and access via context:
import { createAuthClient as createBetterAuthClient } from "better-auth/react";
import { siwnClient } from "better-near-auth/client";
import { useRouter } from "@tanstack/react-router";
export function createAuthClient() {
return createBetterAuthClient({
plugins: [siwnClient({ recipient: getAccount(), networkId: getNetworkId() })],
});
}
export type AuthClient = ReturnType<typeof createAuthClient>;
export function useAuthClient(): AuthClient {
return useRouter().options.context.authClient;
}
const { router } = createRouter({
context: { authClient: createAuthClient() },
});
A module-level singleton is shared across SSR requests, causing data leaks. Router context gives one client per request on server, one per app on client.
The recipient must match the server's siwn() recipient exactly. networkId defaults to "mainnet".
Core Patterns
Sign in with NEAR wallet
await authClient.signIn.near({
onSuccess: () => {
console.log("Signed in!");
},
onError: (error) => {
console.error("Sign in failed:", error.message);
},
});
signIn.near() handles the full flow: wallet connection → NEP-413 message signing → server verification → session creation. It automatically detects wallet capabilities and uses single-step or two-step flow.
Build and relay a delegate action
import { Gas } from "near-kit";
const payload = await authClient.near.buildSignedDelegateAction(
"myapp.near",
(builder, receiverId) => builder.functionCall(receiverId, "some_method", { key: "value" }, {
gas: Gas.Tgas(30),
attachedDeposit: BigInt(0),
})
);
const result = await authClient.near.relayTransaction({ payload });
console.log("Tx hash:", result.data.txHash);
const status = await authClient.near.getRelayStatus(result.data.txHash);
console.log("Status:", status.data.status);
The buildSignedDelegateAction callback receives a TransactionBuilder from near-kit and the receiverId. Use builder.functionCall() to construct actions. The builder calls .delegate() internally and returns a base64 payload string.
buildSignedDelegateAction automatically calls ensureConnected() if the wallet is disconnected, prompting the user to reconnect.
Direct send (user pays gas)
await authClient.near.ensureConnected();
return authClient.near.client
.transaction(accountId)
.functionCall(contractId, "method", args, {
gas: Gas.Tgas(30),
attachedDeposit: BigInt(0),
})
.send({ waitUntil: "FINAL" });
Wallet extensions (Meteor, HERE) may disconnect after the initial sign-in popup. Always call ensureConnected() before .send() — it opens a reconnection prompt if needed. buildSignedDelegateAction does this automatically, but direct .send() does not.
Wallet state and profile management
const accountId = authClient.near.getAccountId();
const canSign = authClient.near.isWalletConnected();
const connected = await authClient.near.ensureConnected();
const state = authClient.near.getState();
const profile = await authClient.near.getProfile();
const aliceProfile = await authClient.near.getProfile("alice.near");
await authClient.near.disconnect();
When the wallet disconnects externally (user signs out from wallet UI), nearState preserves accountId but clears publicKey. Use isWalletConnected() to check if signing operations are available, and getAccountId() for display purposes (works even when disconnected).
Sub-account creation
Create named sub-accounts under a parent account (e.g. myapp.parent.near). The relayer pays gas for the creation transaction.
const { data: availability } = await authClient.near.checkSubAccountAvailability({
subAccountName: "myapp",
});
if (!availability.available) {
console.log("Not available:", availability.reason);
return;
}
const { data: result } = await authClient.near.createSubAccount({
subAccountName: "myapp",
publicKey: "ed25519:...",
});
console.log(result.accountId);
The client does lightweight validation before calling the server: checkSubAccountAvailability returns { available: false, reason: "invalid" } immediately for names failing the regex/length check, skipping the server round-trip.
The server must have subAccount configured (see siwn skill) and a named relayer account.
Account linking and contract view calls
await authClient.near.link({
onSuccess: () => console.log("Linked"),
onError: (err) => console.error(err),
});
await authClient.near.unlink({ accountId: "alice.near" });
const { data } = await authClient.near.listAccounts();
const result = await authClient.near.view({
contractId: "myapp.near",
methodName: "get_value",
args: { account_id: "alice.near" },
});
Client Actions Reference
authClient.near
| Method | Returns | Description |
|---|
nonce(params) | Promise<Response<NonceResponse>> | Request nonce from server |
verify(params) | Promise<Response<VerifyResponse>> | Verify NEP-413 signature |
getProfile(accountId?) | Promise<Response<Profile>> | Get NEAR profile |
view(params) | Promise<Response<ViewResponse>> | Server-side contract view call |
getAccountId() | string | null | Currently connected account ID (persists across disconnects) |
getState() | { accountId, publicKey, networkId } | null | Wallet state |
isWalletConnected() | boolean | Whether wallet is actively connected |
ensureConnected() | Promise<boolean> | Reconnect wallet if disconnected |
disconnect() | Promise<void> | Disconnect wallet |
link(callbacks?) | Promise<void> | Link NEAR account to session |
unlink(params) | Promise<Response> | Unlink NEAR account |
listAccounts() | Promise<Response> | List linked NEAR accounts |
setPrimaryAccount(params) | Promise<Response<SetPrimaryAccountResponse>> | Set primary linked NEAR account |
createSubAccount(params) | Promise<Response<CreateSubAccountResponse>> | Create a sub-account |
checkSubAccountAvailability(params) | Promise<Response<CheckSubAccountAvailabilityResponse>> | Check if a sub-account name is available |
buildSignedDelegateAction(receiverId, buildActions) | Promise<string> | Build + sign delegate action, returns base64 payload |
relayTransaction({ payload }) | Promise<Response<RelayResponse>> | Submit delegate action to relayer |
getRelayStatus(txHash) | Promise<Response<RelayStatusResponse>> | Check relayed tx status |
getRelayerInfo() | Promise<Response<RelayerInfo>> | Get relayer info and balance |
relayHistory() | Promise<Response<RelayHistoryResponse>> | List relayed transactions |
setNetwork(network) | void | Switch active network (mainnet/testnet) |
getNetwork() | "mainnet" | "testnet" | Get currently active network |
getSupportedNetworks() | ("mainnet" | "testnet")[] | List supported networks |
getRecipient(network?) | string | Get configured recipient for a network |
client | Near | Access near-kit Near instance (throws on server) |
authClient.signIn
| Method | Description |
|---|
near(callbacks?) | Connect wallet, sign message, verify — single popup |
Callback Interface
interface AuthCallbacks {
onSuccess?: () => void;
onError?: (error: Error & { status?: number; code?: string }) => void;
}
Common Mistakes
CRITICAL Creating multiple siwnClient instances
Wrong:
function getAuthClient(config) {
return createAuthClient({
plugins: [siwnClient({ recipient: getAccount(config) })],
});
}
Correct:
export const authClient = createAuthClient({
plugins: [siwnClient({ recipient: "myapp.near" })],
});
export function createAuthClient() {
return createBetterAuthClient({
plugins: [siwnClient({ recipient: getAccount() })],
});
}
siwnClient() creates stateful singletons: a nearState atom, a walletConnected atom, a NearConnector with event listeners, and a Near instance. Multiple instances means wallet sign-in populates one atom while your app reads from another. Always create exactly one siwnClient() per app lifecycle.
Source: src/client.ts:64-72
CRITICAL Recipient mismatch between server and client
Wrong:
siwn({ recipient: "myapp.near" });
siwnClient({ recipient: "myapp.com" });
Correct:
siwn({ recipient: "myapp.com" });
siwnClient({ recipient: "myapp.com" });
The client embeds the recipient in the signed message. The server verifies against its own recipient. A mismatch causes the verify endpoint to reject the signature with "Unauthorized: Invalid signature" — which is confusing because the signature is valid, just for a different recipient.
Source: src/client.ts:108, src/index.ts:225
See also: siwn/SKILL.md — server plugin recipient configuration
HIGH Using near.client.send() without ensuring wallet connection
Wrong:
authClient.near.client.transaction(accountId)
.functionCall(contract, "method", args, opts)
.send();
Correct:
await authClient.near.ensureConnected();
authClient.near.client.transaction(accountId)
.functionCall(contract, "method", args, opts)
.send();
Wallet extensions (Meteor, HERE) may disconnect after the initial sign-in popup. ensureConnected() opens a reconnection prompt if needed. buildSignedDelegateAction calls this automatically, but direct .send() does not.
Source: src/client.ts:249-253, src/client.ts:310-317
HIGH Constructing transactions with wrong builder pattern
Wrong:
const result = await near.transaction(accountId)
.functionCall(receiverId, "method", args, { gas: Gas.Tgas(30) })
.send({ waitUntil: "EXECUTED" });
Correct:
const payload = await authClient.near.buildSignedDelegateAction(
"myapp.near",
(builder, receiverId) => builder.functionCall(receiverId, "method", args, {
gas: Gas.Tgas(30),
attachedDeposit: BigInt(0),
})
);
const result = await authClient.near.relayTransaction({ payload });
Delegate actions must be built through buildSignedDelegateAction, which uses near-kit's TransactionBuilder.delegate() method. Direct .send() broadcasts from the client account and requires the user to pay gas — defeating the gasless relay purpose.
Source: src/client.ts:240-260
See also: relay/SKILL.md — relay endpoint validation and whitelisting
MEDIUM Using authClient.near.verify directly instead of signIn.near
Wrong:
await authClient.near.verify({
signedMessage: someMessage,
message: "Sign in to myapp.com",
recipient: "myapp.com",
nonce: someNonce,
accountId: "alice.near",
});
Correct:
await authClient.signIn.near({
onSuccess: () => { },
onError: (error) => { console.error(error); },
});
signIn.near() handles wallet connection, NEP-413 message signing, nonce management, and server verification in a single call. Calling verify directly requires manually constructing all parameters and does not handle wallet connection.
Source: src/client.ts:402-437
MEDIUM Not subscribing to wallet disconnect events
Wrong:
const accountId = authClient.near.getAccountId();
Correct:
import { useStore } from "nanostores/react";
const { nearState } = authClient.$store;
const state = useStore(nearState);
const canSign = authClient.near.isWalletConnected();
When the wallet disconnects externally (user signs out from wallet UI), nearState preserves accountId but clears publicKey and sets walletConnected to false. Use isWalletConnected() to check if signing operations are available, and getAccountId() for display purposes (works even when disconnected).
Source: src/client.ts:65-66, src/client.ts:102-108
MEDIUM Creating sub-accounts without checking availability first
Wrong:
await authClient.near.createSubAccount({
subAccountName: "myapp",
publicKey: "ed25519:...",
});
Correct:
const { data } = await authClient.near.checkSubAccountAvailability({
subAccountName: "myapp",
});
if (!data.available) {
console.log("Sub-account not available:", data.reason);
return;
}
await authClient.near.createSubAccount({
subAccountName: "myapp",
publicKey: "ed25519:...",
});
Always check availability first to avoid unnecessary server round-trips and 409 CONFLICT errors. The availability check is cheap (regex + length check client-side, then RPC account lookup server-side).
Source: src/client.ts:537-548, src/index.ts:1406-1412