10 KiB
title, description
| title | description |
|---|---|
| Frontend with Solana Kit | Build React and Next.js Solana apps with a Kit plugin client, Wallet Standard connection via @solana/kit-plugin-wallet (+ its React hooks), and @solana/react client bindings. |
Frontend with Solana Kit (Next.js / React)
Goals
- One Kit client instance for the app (RPC + wallet + transaction sending)
- Wallet Standard-first discovery/connect (no wallet-specific adapters)
- Minimal "use client" footprint in Next.js (hooks only in leaf components)
- Transaction sending that is observable, cancelable, and UX-friendly
Recommended dependencies
@solana/kit(v7+)@solana/kit-plugin-rpc,@solana/kit-plugin-wallet(wallet React hooks ship in@solana/kit-plugin-wallet/react)@solana/react(v7+ — Kit client bindings:ClientProvider,useClient, data hooks)swr(peer dep of@solana/react/swr) or@tanstack/react-query(peer dep of@solana/react/query) — pick whichever your app already uses@solana-program/system,@solana-program/token,@your-program/codama-clientetc. (only what you need)
solanaRpc already bundles transaction planning/sending — you do not need @solana/kit-plugin-instruction-plan in apps.
Do not use @solana/client / @solana/react-hooks (framework-kit) for new work — that stack is stale; the maintained path is Kit plugins + @solana/react. Do not use @solana/wallet-adapter-* for new apps either; Wallet Standard discovery covers modern wallets.
Bootstrap recommendation
Prefer create-solana-dapp and pick a Kit template for new projects.
Client setup (Next.js App Router)
Create a single wallet-backed client, export its type, and provide it via ClientProvider from @solana/react.
Example app/providers.tsx:
'use client';
import React from 'react';
import { createClient } from '@solana/kit';
import { solanaRpc } from '@solana/kit-plugin-rpc';
import { walletSigner } from '@solana/kit-plugin-wallet';
import { ClientProvider } from '@solana/react';
const rpcUrl =
process.env.NEXT_PUBLIC_SOLANA_RPC_URL ?? 'https://api.devnet.solana.com';
// One client for the whole app. The connected wallet fills payer + identity.
export const client = createClient()
.use(walletSigner({ chain: 'solana:devnet' }))
.use(solanaRpc({ rpcUrl }));
// Export the client type so every useClient<AppClient>() call in the app
// is fully typed (rpc, wallet, sendTransaction, ...).
export type AppClient = Awaited<typeof client>;
export function Providers({ children }: { children: React.ReactNode }) {
return <ClientProvider client={client}>{children}</ClientProvider>;
}
Then wrap app/layout.tsx with <Providers>.
Wallet connection
Use the React hooks from @solana/kit-plugin-wallet/react — state hooks (useWallets, useConnectedWallet, useWalletStatus, useIsWalletReady), action hooks (useConnect, useDisconnect, useSignIn, useSignMessage), useSelectAccount (synchronous — returns the bound function, not an action), plus a WalletReadyGate component for the discovery warm-up. Every hook takes the wallet-enabled client as its first argument:
'use client';
import {
useConnect,
useConnectedWallet,
useDisconnect,
useWallets,
WalletReadyGate,
} from '@solana/kit-plugin-wallet/react';
import type { AppClient } from './providers';
function WalletButton({ client }: { client: AppClient }) {
const wallets = useWallets(client);
const connected = useConnectedWallet(client);
const { dispatch: connect } = useConnect(client);
const { dispatch: disconnect } = useDisconnect(client);
if (!connected) {
return wallets.map((wallet) => (
<button key={wallet.name} onClick={() => connect(wallet)}>
Connect {wallet.name}
</button>
));
}
return (
<div>
<p>Connected: {connected.account.address}</p>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
);
}
// Hide wallet UI until Wallet Standard discovery settles
export const Wallet = ({ client }: { client: AppClient }) => (
<WalletReadyGate client={client} fallback={<p>Loading wallets…</p>}>
<WalletButton client={client} />
</WalletReadyGate>
);
Outside React (or for imperative flows), the same state is on the client: client.wallet.getState() returns { wallets, connected, status } and client.wallet.connect(wallet) / disconnect() / selectAccount(account) drive the connection.
Sending transactions
With the wallet plugin installed, client.sendTransaction plans, asks the wallet to sign, and sends. Wrap it in useAction from @solana/react so pending/error state, abort-on-resend, and stale-while-revalidate come for free instead of hand-rolled useState:
'use client';
import { address, sol, solToLamports } from '@solana/kit';
import { getTransferSolInstruction } from '@solana-program/system';
import { useAction, useClient } from '@solana/react';
import type { AppClient } from '@/app/providers';
function TipButton({ to }: { to: string }) {
const client = useClient<AppClient>();
const { dispatch, isRunning, error, data: signature } = useAction(
async (signal: AbortSignal, recipient: string) => {
const ix = getTransferSolInstruction({
source: client.payer,
destination: address(recipient),
// `sol()` returns a fixed-point value; instructions take Lamports
amount: solToLamports(sol('0.01')),
});
// Forward the signal so a superseded send is actually cancelled
const result = await client.sendTransaction([ix], { abortSignal: signal });
return result.context.signature;
},
);
return (
<>
<button disabled={isRunning} onClick={() => dispatch(to)}>
{isRunning ? 'Sending…' : error ? 'Retry' : 'Tip 0.01 SOL'}
</button>
{signature ? <a href={`https://explorer.solana.com/tx/${signature}`}>View</a> : null}
</>
);
}
Use dispatch in event handlers — it returns void and never throws, so it can't produce an unhandled rejection. Full hook semantics are in kit/react.md.
Data fetching and subscriptions
@solana/react ships data hooks — useRequest (one-shot reads), useSubscription (websocket streams), useTrackedData (a one-shot read seeded into a subscription, slot-deduped), and useAction — plus adapters for SWR (@solana/react/swr) and TanStack Query (@solana/react/query). Prefer these over hand-rolled polling, and always call useClient<AppClient>() with your exported client type. See kit/react.md for the per-hook return shapes and gotchas.
Balance and other live account data: use useTrackedDataSWR. It fires the initial RPC fetch and the account subscription together, slot-dedupes them so an out-of-order arrival never regresses the displayed value, and routes the result through SWR's cache so every component on the same key shares one connection. A Next.js-shaped hook:
'use client';
import { useMemo } from 'react';
import { type Address, type Lamports } from '@solana/kit';
import { useClient } from '@solana/react';
import { useTrackedDataSWR } from '@solana/react/swr';
import type { AppClient } from '@/app/providers';
export function useBalance(accountAddress?: Address) {
const { rpc, rpcSubscriptions } = useClient<AppClient>();
// Passing `null` when there is no address is what gates the hook off —
// it must be memoized, since spec identity drives teardown and re-run.
const spec = useMemo(
() =>
accountAddress
? {
initialValueSource: rpc.getBalance(accountAddress, { commitment: 'confirmed' }),
initialValueMapper: (lamports: Lamports) => lamports,
streamSource: rpcSubscriptions.accountNotifications(accountAddress, {
commitment: 'confirmed',
}),
streamValueMapper: ({ lamports }: { lamports: Lamports }) => lamports,
}
: null,
[rpc, rpcSubscriptions, accountAddress],
);
const { data, error } = useTrackedDataSWR(
accountAddress ? ['balance', accountAddress] : null,
spec,
);
return {
lamports: data?.value ?? null,
isLoading: accountAddress != null && data == null && error == null,
error,
};
}
Multi-cluster apps: include the cluster in the cache key, and derive it from the same source that built the client — the client is typically rebuilt one render after the selection flips, so a key read from selection state binds the new network's fetch to the previous network's rpc. The Kit example app stamps chain onto the client with extendClient and reads it back off useClient<AppClient>() to keep the two in lockstep.
Render lamports with the Kit helpers rather than dividing by 1e9:
import { formatDecimalFixedPoint, lamportsToSol } from '@solana/kit';
const solFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 5 });
function BalanceDisplay({ accountAddress }: { accountAddress: Address }) {
const { lamports } = useBalance(accountAddress);
if (lamports == null) return <span>–</span>;
return <span>{formatDecimalFixedPoint(solFormatter, lamportsToSol(lamports))} ◎</span>;
}
For Next.js: keep server components server-side; only leaf components that call hooks should be client components. Server-side reads can use a plain Kit RPC client (no wallet plugin).
Transaction UX checklist
- Disable inputs while a transaction is pending
- Provide a signature immediately after send
- Track confirmation states (processed/confirmed/finalized) based on UX need
- Show actionable errors:
- user rejected signing
- insufficient SOL for fees / rent
- blockhash expired / dropped
- account already in use / already initialized
- program error (custom error code)
Legacy apps
- App built on web3.js v1 + wallet-adapter? Migrate to web3.js v3 (Kit internals, same classes; currently RC) first — see kit-web3-interop.md for routing to the official migration skill — then adopt Kit plugins incrementally.
- Found
@solana/client/@solana/react-hooks(framework-kit)? Migrate to the Kit plugin client +@solana/react:createClient({ endpoint, walletConnectors })becomescreateClient().use(walletSigner(...)).use(solanaRpc(...)), and framework-kit hooks map to the@solana/kit-plugin-wallet/reacthooks orclient.walletstate.