AppKit Guide
Guide developers consuming @ton/appkit + @ton/appkit-react in their TON apps. Assume React, TypeScript, and TanStack Query basics, but explain TON/AppKit-specific decisions clearly.
For contributing to the @ton/kit monorepo, use kit-dev instead. This skill is for library-consumer answers: code the user's app would write, not package internals.
Response workflow
- Classify the task first. If the user wants a complete feature quickly, prefer drop-in components. If they describe custom UX, use hooks. If they report stale data, real-time, network, or SSR symptoms, start with the relevant gotcha before writing code.
- Give runnable integration shape. Include provider setup when the bug could come from missing
QueryClientProvider,AppKitProvider, connector config, CSS import, or streaming providers. Avoid isolated hook snippets that omit required context. - Use AppKit semantics exactly. Balances are already-formatted decimal strings (TON or jetton units — never raw nano), transfer amounts are human-readable strings, mutations do not automatically invalidate caches, streaming is opt-in, and wallet state is client-only for SSR.
- Name the tradeoff. Drop-in components are fastest and safest; hooks are for custom UX and require explicit loading/error/cache handling.
What do you want to do?
| Task | Path |
|---|---|
| Set up a new app | pnpm create ton-appkit (scaffolds React+Vite project) or Quick Setup below |
| Wallet connect/disconnect | <TonConnectButton /> or useConnect/useDisconnect |
| Show TON balance | useBalance() + optionally useWatchBalance() for live updates |
| Send TON | <SendTonButton /> or useTransferTon() |
| Send jettons (USDT, etc.) | <SendJettonButton /> or useTransferJetton() |
| Show NFTs | useNfts() + <NftItem /> |
| Swap tokens | <SwapWidget /> or useSwapQuote + useBuildSwapTransaction + useSendTransaction |
| Stake TON | <StakingWidget /> or staking hooks |
| Buy TON/jettons with crypto (onramp) | <CryptoOnrampWidget /> or onramp hooks (useCryptoOnrampProviders + useCryptoOnrampQuote + useCreateCryptoOnrampDeposit + useCryptoOnrampStatus) |
| Use a custom (app-defined) provider | useCustomProvider<T>(id) |
| Sign message | useSignText / useSignBinary / useSignCell |
| Mainnet/testnet support | Configure both networks + useDefaultNetwork() + explicit network params |
| Real-time updates | Register streaming provider + mount useWatchBalance / useWatchTransactions |
| Fix Next.js hydration | 'use client' providers + mount gate or dynamic import |
| Refresh balance after send | queryClient.invalidateQueries({ queryKey: ['balance'] }) |
| Clear stale data on disconnect | queryClient.removeQueries for wallet-scoped keys |
Drop-in components (<TonConnectButton />, <SendTonButton />, <SwapWidget />, etc.) are the fastest path. Use hooks only for custom UX.
For extended swap/staking/jetton recipes, see skill-reference/recipes.md.
Packages
@ton/appkit— core SDK:AppKitclass, actions, connectors (TonConnect), DeFi managers.@ton/appkit-react— React hooks,AppKitProvider, UI components.@tanstack/react-query— required peer dependency.
Quick Setup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AppKit, Network, createTonConnectConnector } from '@ton/appkit';
import { AppKitProvider } from '@ton/appkit-react';
import '@ton/appkit-react/styles.css';
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
});
const appKit = new AppKit({
networks: {
[Network.mainnet().chainId]: {
apiClient: { url: 'https://toncenter.com', key: 'YOUR_API_KEY' },
},
// Keep testnet too so users on testnet wallets aren't silently rejected.
// Drop it once you're production-only.
[Network.testnet().chainId]: {
apiClient: { url: 'https://testnet.toncenter.com', key: 'YOUR_API_KEY' },
},
},
connectors: [
createTonConnectConnector({
tonConnectOptions: { manifestUrl: 'https://your-app.com/tonconnect-manifest.json' },
}),
],
});
// QueryClientProvider MUST wrap AppKitProvider
function App() {
return (
<QueryClientProvider client={queryClient}>
<AppKitProvider appKit={appKit}>{/* your app */}</AppKitProvider>
</QueryClientProvider>
);
}
For Next.js: add 'use client' to the providers file and gate wallet-dependent UI until mount, or use dynamic(() => import(...), { ssr: false }). There is no ssr flag on AppKitConfig — the SSR boundary is purely a React/Next concern, not an AppKit option. Wallet state (useAddress, balances, etc.) is client-only by construction; render a stable placeholder on the server and swap in wallet-dependent UI after useEffect/mount.
Browser polyfills (Vite / Webpack / Rspack)
@ton/core (transitive dep) uses Node's Buffer and process. Browser bundlers don't include these by default — without polyfills you get a white screen and ReferenceError: Buffer is not defined at runtime.
For Vite, install vite-plugin-node-polyfills and add it to vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
export default defineConfig({ plugins: [react(), nodePolyfills()] });
If the plugin clashes with your project (e.g. nested workspace, alias resolution warnings), use a manual polyfill instead — pnpm add buffer process, then create src/polyfills.ts:
import { Buffer } from 'buffer';
import process from 'process';
(globalThis as { Buffer?: typeof Buffer }).Buffer = Buffer;
(globalThis as { process?: typeof process }).process = process;
Import ./polyfills as the first line of main.tsx (before any AppKit code).
Next.js: no polyfill needed — Next's Webpack config handles Node globals automatically.
First-touch send: self-transfer
For a safe end-to-end test of the send flow, use useAddress() as the recipient — the user sends 0.01 TON to themselves. Validates wallet, network, signing, and on-chain confirmation without risk:
const address = useAddress();
{address && <SendTonButton recipientAddress={address} amount="0.01" />}
For real recipients, paste a TON address from Tonkeeper (UQ… non-bounceable for wallets, EQ… bounceable for contracts). Random/made-up strings without valid checksums make Tonkeeper's simulator say "no changes to your account" and refuse to sign.
Hooks Reference
Hooks come from @ton/appkit-react. Queries return TanStack { data, isLoading, isError, error }. Mutations return { mutate, mutateAsync, isPending, error, data, reset }.
Wallet
useConnectors() · useConnect() · useDisconnect() · useSelectedWallet() · useAddress() · useConnectedWallets()
useConnect() returns a mutation; call it with mutate({ connectorId }), passing the connector's id from useConnectors() (not the connector object itself). On iOS, the deep link that opens the wallet only fires if connect() runs in the same synchronous tick as the user's click — any await before it kills the user-activation token and the wallet won't open.
const connectors = useConnectors();
const { mutate: connect, isPending } = useConnect();
// ✅ Good: synchronous in click handler
<button => connect({ connectorId: connector.id })}>Connect</button>
// ❌ Bad on iOS: awaiting before connect() loses the user gesture
<button () => {
await analytics.track('connect_clicked'); // <-- breaks deep link on iOS
connect({ connectorId: connector.id });
}}>Connect</button>
If you need side effects, fire-and-forget them (no await) or run them in mutation: { onSuccess }.
Balance & assets
useBalance()/useBalanceByAddress({ address, network? })— returns a decimal TON string already formatted with 9 decimals (e.g."0.500000000"for a wallet holding 0.5 TON). It is NOT raw nano and NOT abigint. Render directly or format withIntl.NumberFormat; do not pass it throughformatUnits(_, 9)again — that re-slices on the decimal point and yields garbage like"0..500000000". Passnetworkexplicitly for cross-network reads:useBalanceByAddress({ address, network: Network.testnet() }).useWatchBalance()/useWatchBalanceByAddress(...)— real-time updates (requires streaming provider)useJettons()— returns{ jettons: Jetton[], addressBook }. Usedata?.jettons, notdatadirectly. EachJetton.balanceis already formatted via the jetton's owndecimalsNumber(e.g."1.5"for 1.5 USDT) — same rule: don't re-format withformatUnits. Image URLs live underinfo.image.smallUrl/info.image.url(note theUrlsuffix).useJettonBalanceByAddress({ jettonAddress, ownerAddress, jettonDecimals?, network? })— note the param isownerAddress, notaddress. Returns a pre-formatted decimal string in the jetton's own units (e.g."1.5"for 1.5 USDT). IfjettonDecimalsisn't passed, AppKit looks it up from jetton metadata (one extra read). Same rule asuseBalance: don't pass the result throughformatUnitsagain.useJettonInfo({ address, network? })—addressis the jetton master contract address. Returns{ name, symbol, decimals?, image, … } | null.useJettonWalletAddress({ jettonAddress, ownerAddress, network? })— resolves the owner's individual jetton-wallet contract address for a given jetton.useWatchJettons()/useWatchJettonsByAddress(...)— real-time jetton list updates (requires streaming provider).useWatchBalanceByAddress({ address, network? })— real-time balance for an arbitrary address.useNfts()— returns{ nfts: NFT[], addressBook? }. Usedata?.nfts, notdatadirectly.useNft({ address })(addressis the NFT item's own contract address — NOTtokenAddress)
Sending
useTransferTon() · useTransferJetton() · useTransferNft() · useSendTransaction()
Call with mutate({ recipientAddress, amount: '0.5', comment? }). Amount is a human-readable string — AppKit converts using token decimals. The mutation's data resolves to SendTransactionResponse = { boc, normalizedBoc, normalizedHash } (object, not a bare string).
Param names differ per asset — easy to get wrong from intuition:
- TON:
mutate({ recipientAddress, amount, comment? }) - Jetton:
mutate({ jettonAddress, recipientAddress, amount, comment? })—jettonAddressis the jetton master contract. - NFT:
mutate({ nftAddress, recipientAddress, amount?, comment? })—nftAddress(NOTtokenAddress!) is the individual NFT item's contract address (nft.addressfromuseNfts()). Optionalamountis the forward TON (gas/notification), defaults are sensible. There is no<SendNftButton />drop-in — compose from the hook.
Signing
useSignText() · useSignBinary() · useSignCell()
Network
useNetwork() (selected wallet's network, undefined if not connected) · useDefaultNetwork() returns [defaultNetwork, setDefaultNetwork] · useNetworks() · useBlockNumber()
For mainnet/testnet apps, configure both networks and make reads explicit when the UI lets users choose a network:
const [network, setNetwork] = useDefaultNetwork();
const { data: balance } = useBalanceByAddress({ address, network });
<button => setNetwork(Network.mainnet())}>Mainnet</button>
<button => setNetwork(Network.testnet())}>Testnet</button>
DeFi
- Swap:
useSwapQuote({ from, to, amount, slippageBps? })·useBuildSwapTransaction()·useSwapProviders()·useSwapProvider() - Staking:
useStakingProviders()·useStakingQuote({ amount, direction })·useBuildStakeTransaction()·useStakedBalance({ userAddress })
When you compose these into a custom UI (instead of <SwapWidget /> / <StakingWidget />), you own loading and error state for every step: useSwapQuote().isLoading for the quote, useBuildSwapTransaction().isPending for the build, useSendTransaction().isPending for the send. Disable the submit button on the active step's pending flag, render the active step's error.message, and call reset() for retry. The widgets handle this for you; the hooks deliberately don't.
Crypto Onramp (buy TON/jettons with crypto from another chain)
Onramp lets a user deposit crypto on a source chain (e.g. USDT on Arbitrum) and receive TON or a jetton. It needs a provider registered in AppKit config — same providers: [] array as swap/staking/streaming. The widget renders nothing useful until one is registered:
import { createLayerswapProvider } from '@ton/appkit/crypto-onramp/layerswap';
import { createDecentProvider } from '@ton/appkit/crypto-onramp/decent';
const appKit = new AppKit({
networks: { /* ... */ },
providers: [
createLayerswapProvider(), // config optional
createDecentProvider({ apiKey: 'KEY' }), // apiKey required
],
});
Hooks (from @ton/appkit-react):
useCryptoOnrampProviders()— all registered onramp providers (array).useCryptoOnrampProvider()returns[selected, setSelected]tuple.useCryptoOnrampProviderById({ id })— one provider by id. Param isid.useCryptoOnrampProviderMetadata({ providerId })— provider display metadata (name/logo/refundAddressMode). Synchronous (no query) and the param here isproviderId, notid— the two onramp hooks disagree on the name, easy to get wrong.useCryptoOnrampSupportedCurrencies({ providerId? })—{ source[], destination[] }lists. Cached aggressively (1h stale).useCryptoOnrampQuote({ amount, sourceCurrency, targetCurrency, recipientAddress, isSourceAmount?, providerId? })— query; returns aCryptoOnrampQuotewithsourceAmount/targetAmount/ratein base units (not decimal strings — unlikeuseBalance).useCreateCryptoOnrampDeposit()— mutation; callmutateAsync({ quote, refundAddress }). Returns{ depositId, address, amount, memo?, expiresAt? }— the address/memo the user must send funds to.useCryptoOnrampStatus({ depositId, providerId })— poll status;'pending' | 'success' | 'failed'. Auto-disabled untildepositIdis set.
Drop-in <CryptoOnrampWidget defaultDestination={{ address: 'ton' }} defaultSource={{ chain, address }} /> handles all of this. Use the hooks only for custom UX, and own loading/error per step like swap.
Custom providers
useCustomProvider<T>(id) retrieves an app-defined provider you registered yourself (providers: [myProvider], where myProvider.type === 'custom'). The argument is a positional string id, not an object — unlike every other hook here. Pass your provider's interface as the generic to narrow the return type:
const provider = useCustomProvider<MyProvider>('my-provider'); // MyProvider | undefined
if (!provider) return <p>Provider not registered</p>;
There is no runtime check that the registered provider matches T — the generic is a cast. You own ensuring the id maps to the shape you claim.
Drop-in Components
Full-featured React components from @ton/appkit-react. They handle their own state, loading, and errors:
| Component | Use for |
|---|---|
<TonConnectButton /> |
Wallet connect/disconnect UI |
<SendTonButton recipientAddress amount comment? onSuccess? onError? /> |
Send TON |
<SendJettonButton jetton={{address, symbol, decimals}} recipientAddress amount comment? onSuccess? onError? /> |
Send jettons. ⚠ jetton is an object with { address, symbol, decimals } — NOT a bare address string. address is the jetton master. amount is human-readable (e.g. "5" for 5 USDT); decimals come from the prop, not metadata. NFT transfers go via useTransferNft hook — no drop-in button. |
<SwapWidget tokens={...} /> |
Full swap UI |
<StakingWidget /> |
Full staking UI |
<CryptoOnrampWidget defaultDestination defaultSource /> |
Full onramp UI (deposit address, quote, status). Needs an onramp provider registered. defaultDestination = { address: 'ton' | jettonMaster }, defaultSource = { chain: Caip2ByNetwork.X, address: 'native' | token }. |
<NftItem nft={...} /> |
NFT card (image, name, collection, badge) |
<TransactionProgress boc={...} /> |
Tracks tx until on-chain finalized |
Render-prop pattern for custom UI with built-in state:
<SwapWidget tokens={tokens}>{(ctx) => <YourUI {...ctx} />}</SwapWidget>
Query Key Prefixes
For invalidateQueries / removeQueries. TanStack prefix-matches on the first array element — passing queryKey: ['balance'] invalidates every ['balance', { address: A, network: M }], ['balance', { address: B, network: T }], etc. You rarely need to enumerate addresses; matching by the bare prefix is enough.
| Prefix | Full key shape | Caches |
|---|---|---|
['balance'] |
['balance', { address, network }] |
TON balance |
['nfts'] |
['nfts', { address, network }] |
NFT list |
['nft'] |
['nft', { address, network? }] |
Single NFT (address = NFT item's contract address) |
['jettons'] |
['jettons', { address, network }] |
Jetton list |
['jetton-balance'] / ['jetton-info'] / ['jetton-wallet-address'] |
kebab-case keys with { jettonAddress, ownerAddress } |
Jetton state (kebab-case — camelCase will silently match nothing) |
['stakedBalance'] / ['stakingProviderInfo'] / ['stakingQuote'] |
camelCase keys | Staking |
['swapQuote'] |
['swapQuote', { amount, from, to }] |
Swap quotes |
['crypto-onramp-quote'] / ['crypto-onramp-status'] / ['crypto-onramp-supported-currencies'] |
kebab-case keys | Onramp quotes / deposit status / currency lists |
Four Critical Patterns
1. Real-time updates (streaming)
Streaming is opt-in. Two requirements: register a provider + mount the watch hook.
import { createTonCenterStreamingProvider } from '@ton/appkit';
const appKit = new AppKit({
networks: { /* ... */ },
providers: [
createTonCenterStreamingProvider({ network: Network.mainnet(), apiKey: 'KEY' }),
],
});
function BalanceDisplay() {
const { data: balance } = useBalance(); // already-formatted decimal string like "0.5"
useWatchBalance(); // writes WS updates into useBalance cache
return <p>{balance ?? '—'} TON</p>;
}
Without both, useBalance is one-shot.
Streaming-provider auth quirks (keyless != work):
createTonCenterStreamingProvider({ network, apiKey })—apiKeyis optional; keyless works but is heavily rate-limited.createTonApiStreamingProvider({ network, apiKey })—apiKeyis effectively required. Keyless WebSocket connections towss://tonapi.io/streaming/v2/wsare closed by the gateway with HTTP 401; the provider then reconnects in a loop and floods the console withWebSocket errorlines. Get a token from https://tonconsole.com or fall back tocreateTonCenterStreamingProviderif you want a keyless option.
2. Cache after mutations
Mutations don't auto-invalidate. Use invalidateQueries after transfers and removeQueries after disconnect:
const queryClient = useQueryClient();
// After transfer: invalidate so the next render refetches
useTransferTon({
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['balance'] }) },
});
// After disconnect: remove entirely so old wallet's data can't flash
useDisconnect({
mutation: {
onSuccess: () => {
['balance', 'nfts', 'nft', 'jettons', 'jetton-balance', 'jetton-info', 'jetton-wallet-address', 'stakedBalance'].forEach(key =>
queryClient.removeQueries({ queryKey: [key] }),
);
},
},
});
removeQueries is correct on disconnect because there's no wallet to refetch for — leaving stale entries would flash the old wallet's data before the new wallet loads.
For a stronger guarantee when wallet switches happen mid-flight (pending tx, hooks holding stale data), wrap wallet-scoped UI with key={address} so React remounts the whole subtree on address change:
const address = useAddress();
return <WalletScopedUI key={address ?? 'disconnected'} />;
This kills any in-flight mutation state, useState, and refs tied to the previous wallet — cleanest fix for "spinner stuck forever after switching wallets". Combine with removeQueries on disconnect for the cache layer.
3. Transaction confirmation is not instant
useTransferTon / useSendTransaction resolve when the wallet accepts the tx — not when it's confirmed on-chain. Balances won't reflect the change immediately even after onSuccess. data is SendTransactionResponse = { boc, normalizedBoc, normalizedHash } — an object, not a bare BOC string. Two patterns:
// (a) Drop-in: <TransactionProgress /> tracks the tx until finalized
const { mutate, data } = useTransferTon();
{data && <TransactionProgress boc={data.boc} => /* now confirmed */} />}
// (b) Hooks: invalidate on accept; the next refetch picks up the new state once on-chain
useTransferTon({
mutation: { onSuccess: () => queryClient.invalidateQueries({ queryKey: ['balance'] }) },
});
To track a tx by hash (e.g. backend webhook lookup), use data.normalizedHash.
Pair with useWatchBalance() + streaming if you want the balance to refresh the instant confirmation lands without a manual refetch.
4. Transaction error handling
const { mutate, isPending, error, reset } = useTransferTon();
const isUserRejection = (err: Error) => /reject|cancel|abort/i.test(err.message);
<button disabled={isPending} => mutate(params)}>Send</button>
{error && (
<>
<p>{isUserRejection(error) ? 'Cancelled' : `Error: ${error.message}`}</p>
<button again</button>
</>
)}
Disable only on isPending, not isError (button must re-enable so user can retry). Use reset() for retry. For swap/staking, @ton/appkit exports DefiError (with DefiErrorCode) for typed handling.
No-wallet fallback
When the user has no TON wallet installed (no browser extension, no Tonkeeper on the device), <TonConnectButton /> still works — but the experience changes:
- On mobile without Tonkeeper installed, the universal link opens the App Store / Google Play install flow.
- On desktop without a browser-extension wallet, TonConnect opens a modal with QR codes — the user scans from their mobile wallet to connect. This is intentional and works without any code from you.
For a custom Connect button, you can detect emptiness via useConnectors() and render a fallback CTA:
const connectors = useConnectors();
if (connectors.length === 0) {
return (
<a href="https://tonkeeper.com/download" target="_blank" rel="noreferrer">
Install Tonkeeper to continue
</a>
);
}
But for most apps the right answer is just use <TonConnectButton /> and rely on TonConnect's built-in universal/QR/install-redirect flow — it covers all three cases (extension, mobile-installed, mobile-not-installed → install link, desktop → QR) without you wiring it.
Common Gotchas
QueryClientProviderwrapsAppKitProvider— not the other way around.AppKitProviderdoesn't create its own QueryClient.useBalancereturns a pre-formatted TON decimal string (e.g."0.500000000"), not raw nano and notbigint. Same foruseJettons().data.jettons[i].balance(formatted using the jetton's own decimals). Render directly or format withIntl.NumberFormat; callingformatUnits(balance, 9)again will re-slice the string on the decimal point and produce"0..500000000".- Amounts are strings (
'0.5', not500000000n). AppKit applies token decimals internally. - Streaming is opt-in — needs both a provider in config and
useWatchBalancemounted. - Network mismatch breaks transactions — if the connected wallet is on testnet but your
defaultNetworkis mainnet (or vice versa), TonConnect rejects the tx and youruseTransferTonmutation lands inerrorwith a network-mismatch message. SetdefaultNetwork, expose switching withuseDefaultNetwork(), and passnetworkinto address-based reads. Check before sending:
Mainnet chainIdconst walletNetwork = useNetwork(); const [defaultNetwork] = useDefaultNetwork(); const networkMismatch = walletNetwork && walletNetwork.chainId !== defaultNetwork.chainId;-239, testnet-3. - iOS deep links need synchronous click — call
connect()directly in the handler, noawaitbefore it (see Wallet section above for the anti-pattern). - Telegram Mini App return — configure
tonConnectOptions.actionsConfiguration.returnStrategy. - React 19 / Next 15 hook errors — check
@tonconnect/ui-reactversion + runpnpm why reactfor duplicates. <StakingWidget />+ keyless TonCenter = HTTP 429 cascade — while the widget is mounted, its internal queries poll withrefetchInterval: 5000(three queries: provider info, staked/token balances, quote), and each refresh can hit several TonCenter endpoints (getPoolBalance/getPoolData/ APY). The Tonstakers provider itself has NO internal timer — registeringcreateTonstakersProvider()alone causes no traffic; it only adds a 30s-TTL cache around on-demand reads. With a keylessapiClient: { url: 'https://toncenter.com' }, the widget's polling quickly exhausts the IP's anonymous TonCenter quota and cascades HTTP 429s into otherwise-unrelated reads (useBalance,useJettons,useNfts). Symptom: balance/jettons/nfts hang or fail, staking widget itself stays empty. Fix: set a real TonCenterapiKeyon the apiClient, or don't mount<StakingWidget />until you have one.<SwapWidget />swallows build/send errors silently — itsctx.errorcarries only client-side validation messages. Failures fromuseBuildSwapTransaction(e.g. Omniston returningInternal server error: 5: [hash], an expired quote, no liquidity at execution time) and fromuseSendTransactionbecome unhandled promise rejections: the Continue button appears to do nothing, no toast, no in-widget error. Diagnose in DevTools Console (look forUnhandled rejection: SwapError: …). If you're composing your own UI via the render-prop, wrapctx.sendSwapTransaction()intry/catchand surface the error yourself, or attach awindow.addEventListener('unhandledrejection', ...)handler. For custom flows, the hooks (useSwapQuote→useBuildSwapTransaction→useSendTransaction) each expose their ownerrorfield — use them directly instead.