Client-Side Signing
Build and send transactions in the browser with kit + wallet adapters.
Recipe
"use client";
import { createSolanaRpc, signTransactionMessageWithSigners } from "@solana/kit";When to reach for this:
- User-paid transactions and NFT mints.
- DeFi swaps requiring wallet approval.
- Any action needing private user keys.
Working Example
"use client";
import { useCallback } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import {
createSolanaRpc,
createTransactionMessage,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstruction,
signTransactionMessageWithSigners,
sendAndConfirmTransactionFactory,
createSolanaRpcSubscriptions,
} from "@solana/kit";
import { getTransferSolInstruction } from "@solana-program/system";
export function SendButton({ to, lamports }: { to: string; lamports: bigint }) {
const wallet = useWallet();
const rpc = createSolanaRpc(process.env.NEXT_PUBLIC_RPC_URL!);
const rpcSubscriptions = createSolanaRpcSubscriptions(process.env.NEXT_PUBLIC_RPC_WSS_URL!);
const onSend = useCallback(async () => {
if (!wallet.publicKey) return;
const { value: bh } = await rpc.getLatestBlockhash().send();
// Bridge wallet to kit signer (use your template's helper)
let msg = createTransactionMessage({ version: 0 });
// set fee payer signer, lifetime, transfer ix...
const signed = await signTransactionMessageWithSigners(msg);
const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions });
await sendAndConfirm(signed, { commitment: "confirmed" });
}, [wallet, rpc, rpcSubscriptions, to, lamports]);
return <button onClick={onSend}>Send</button>;
}What this demonstrates:
- Entire sign pipeline runs client-side only.
- Fresh blockhash per click.
- Confirm via WSS factory.
Deep Dive
How It Works
- Message built in browser; wallet signs without exporting secrets.
- Simulation optional step before wallet modal.
- Next.js serializes no transaction bytes through server for self-custody flows.
Gotchas
- Server Action trying to use window wallet - fails. Fix: keep in client component.
- Stale closure on wallet - send after disconnect. Fix: guard on connected state.
- Missing WSS env - confirm hangs. Fix: provide public WSS URL.
- Blockhash expiry during wallet approval - retry rebuild.
- Not disabling button while pending - double spend attempts.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Server-sponsored tx | Onboarding gasless | Full self-custody |
| Embedded wallet | Web2 UX | Power users |
FAQs
App Router?
Providers in client layout.tsx; pages mix server + client components.
Secrets?
Server env only for keypairs; NEXT_PUBLIC_ for RPC URLs.
kit 7.0.0?
All examples use @solana/kit, not web3.js v1.
Wallet?
Signing only in client components.
Caching?
Revalidate on-chain reads carefully - chain is source of truth.
Actions?
HTTPS endpoints returning Solana Actions JSON.
Mobile?
Separate React Native app with MWA.
Testing?
Surfpool for integration; mock RPC in unit tests.
Mainnet?
Separate env files and program IDs.
Errors?
Decode simulation logs for user-facing messages.
Related
- Signing Transactions & Messages - wallet sign
- Transaction UX - feedback
- Building Transaction Messages - compose
Stack versions: This page was written for Agave 4.1.1, Solana CLI 3.0.10, Anchor 0.32.1, anchor-lang 0.32.1, Rust 1.91.1, @solana/kit 7.0.0, Surfpool 0.12.0, and LiteSVM 0.6.x.