Solana frontend developers have lived with @solana/web3.js v1 since 2020. It was class-based, single-package, and it worked. That era is closing. Anza shipped @solana/web3.js 2.0 — now rebranded as @solana/kit — and the release-candidate label has been dropped. It is the recommended SDK for new Solana dApp code.
The change is not cosmetic. Almost every line of your transaction-building code is touched, and so is the way bundlers handle your output. This post walks through what actually changes, with the conceptual shifts you need to map onto your existing code.
Why the rewrite shipped at all
Three forces drove it:
- Bundle size. v1 shipped one package containing everything. Tree-shaking could not strip parts you did not use, so a dApp that only sent SOL transfers still paid for the SPL token, stake, and vote serializers. In one published benchmark the Solana Explorer landing bundle dropped 26% (311 KB → 226 KB) after migrating.
- Performance. v1 used userland Ed25519 implementations. Modern runtimes — Node, Safari 17+, Chrome — ship Web Crypto APIs that handle Ed25519 natively. Routing signing through them is roughly 10× faster than the JavaScript path.
- API ergonomics. v1's class-heavy model (
Connection,Transaction,Keypair) coupled state and behavior tightly. Mocking aConnectionfor unit tests meant subclassing or stubbing a large surface.
@solana/kit answers all three with one design choice: ship a set of small, focused modules of pure functions. @solana/rpc, @solana/transaction-messages, @solana/transactions, @solana/signers, @solana/accounts, @solana/codecs, @solana/addresses. You import what you use. The optimizer drops the rest.
The RPC client is now a thin proxy
In v1 you wrote new Connection(url). The Connection object was both the network client and a grab bag of helper methods. In @solana/kit, the RPC client is a thin proxy that does nothing else:
import { createSolanaRpc } from '@solana/kit'
const rpc = createSolanaRpc('http://127.0.0.1:8899')
const slot = await rpc.getSlot().send()Two things to note. First, every RPC call is staged: build the request, then .send() it. That separation lets you batch, retry, or swap transports without subclassing. Second, the RPC client does not carry transaction-building state — that lives in dedicated packages and is composed at the call site.
Building a v0 transaction is now a pipe of pure functions
Versioned transactions (v0) are the default in @solana/kit. You build them by composing pure functions instead of mutating a Transaction object. The Anza-maintained pattern looks like this:
import { pipe } from '@solana/functional'
import {
createTransactionMessage,
setTransactionMessageFeePayer,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstructions,
signTransaction,
sendAndConfirmTransaction,
lamports,
} from '@solana/kit'
import { getTransferSolInstruction } from '@solana-program/system'
const message = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayer(feePayer, tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(blockhash, tx),
(tx) => appendTransactionMessageInstructions([
getTransferSolInstruction({
source,
destination,
amount: lamports(1_000_000n),
}),
], tx),
)
const signed = await signTransaction([signer], message)
await sendAndConfirmTransaction(signed)What is happening line by line: createTransactionMessage returns an empty v0 message. Each subsequent step takes a message and returns a new message with one more thing set — the fee payer, the blockhash that bounds the transaction's lifetime, the instructions. pipe is just function composition; nothing here mutates.
Two changes worth absorbing:
- The transaction message is built up immutably. There is no
Transactionclass with.add(). Every step is a value transformation. That makes intermediate states trivial to log, snapshot, and assert on in tests. - Amounts are
bigint. SOL transfers, token amounts, anything mapping to a Rustu64. Mixingnumberandbigintis a TypeScript error, so you cannot accidentally lose precision on amounts above2^53lamports.
The instruction itself comes from a separate package — @solana-program/system — instead of from a SystemProgram class. That same pattern repeats across every program: SPL token instructions live in @solana-program/token, and so on. If your dApp does not use SPL, you do not pay the bundle cost for it.
This is also the right moment to remember the Solana execution model that the SDK is serving: every account a transaction touches is an explicit input, programs do not hold internal state, and a v0 transaction can pull in extra accounts via Address Lookup Tables. @solana/kit exposes those concerns directly — there is a function for adding ALTs to a message — instead of hiding them behind class methods. The shape of the SDK now matches the shape of the protocol.
What this changes for testing your dApp
A real Solana dApp does not just call RPC — it asks Phantom or another wallet extension to sign. The wallet extension sees the v0 transaction your dApp built, displays the parsed instructions, and the user either signs or rejects. The kit-based code paths that build that transaction are easier to unit test because they are pure functions: feed in known accounts and a known blockhash, snapshot the resulting message bytes, diff on regression. But the wallet popup is still where users decide. For end-to-end tests that drive a real browser and a real wallet extension, libraries like @avalix/chroma automate the Playwright side of that boundary so the connect-and-sign path is exercised on every run rather than only by hand.
Where to start the migration
Most dApps do not need a big-bang switch. A practical order:
- Add
@solana/kitalongside your existing@solana/web3.jsv1 install. - Migrate read-only RPC calls first —
getSlot,getAccountInfo,getBalance. They are isolated and easy to verify against the v1 result. - Migrate transaction building one user flow at a time. Each call moves from a
Transactionclass to apipeof message-building functions. - Remove the v1 dependency last, after every flow is on
@solana/kit.
The headline benefit is not the 10× faster signing or the smaller bundle, useful as those are. It is that your transaction code becomes data — pure functions producing pure values — and that data is far easier to log, snapshot, diff, and assert on than a stateful object graph ever was.