Skip to main content

How to Migrate from Solana web3.js v1 to v3

Updated on
Oct 08, 2026

13 min read

Overview​

For most of Solana's history, @solana/web3.js v1 was how TypeScript code talked to the network. Its Connection, PublicKey, Keypair, and Transaction classes were easy to pick up, but the code under them aged badly. v1 depends on jayson, superstruct, BufferLayout, and BN.js, needs a Buffer polyfill in the browser, signs with synchronous JavaScript crypto, and ships as one bundle that bundlers can't trim.

Much of that code duplicates features built into modern JavaScript. BN.js duplicates bigint, the Buffer polyfill duplicates typed arrays, and the bundled crypto duplicates WebCrypto. Each of those packages is another dependency an attacker could compromise to ship code inside web3.js.

Anza rebuilt the library from scratch and released it as Solana Kit. Kit splits everything into small functions so bundlers drop what you don't import, signs with the platform's WebCrypto API, returns lamport values as bigint, and types every RPC method. The cost was the API. Kit has no Connection or Transaction classes, so moving a v1 codebase to Kit meant rewriting every call site, and libraries built on v1, such as Anchor's TypeScript client, didn't plug into it.

web3.js v3 keeps the v1 classes and replaces everything under them with Kit, so existing code gets Kit's internals without a rewrite. The upgrade still takes some work, because Kit's async signing, bigint values, and Uint8Array account data show through the old classes.

In this guide, you will see which Kit package sits under each web3.js class, compare the same SOL transfer in v1 and v3, and fix each breaking change with before-and-after code.


TLDR
  • web3.js v3 keeps the v1 classes and runs them on Solana Kit
  • Most of the upgrade is adding await and switching to bigint and Uint8Array
  • Kit instructions and signers work directly with v3 classes

What You Will Do​


  • Learn which Kit package each web3.js class delegates to
  • Compare the same SOL transfer written in web3.js v1 and web3.js v3
  • Install v3 and fix each category of breaking change with before-and-after code
  • Pass Codama-generated instructions and Kit signers through the web3.js API
  • Audit a v1 codebase with the compiler, a search list, and the official migration skill

What You Will Need​


  • Node.js version 24 or higher
  • The Solana CLI so you can run solana-test-validator for the examples
  • An existing project on @solana/web3.js v1, or an empty folder to try the samples in
  • A Quicknode Solana endpoint when you point the migrated code at devnet or mainnet. The examples run against a local validator so you can airdrop freely

Dependencies Used in This Guide​

DependencyVersion
@solana/web3.js3.0.1
@solana/kit8.4.0
@solana-program/system0.15.0
tsx4.23.15
solana cli4.2.2

How web3.js v3 Is Built on Kit​

web3.js v3 is a thin, class-shaped layer over Kit. Every class you call delegates to a Kit package, and the behaviors of those packages are what changed between v1 and v3.

A layered stack of the web3.js v3 architecture. The top layer is the classes your code calls, Connection, PublicKey, Keypair, and Transaction, with the same names as v1. Every layer below is Solana Kit: typed RPC and subscriptions, the address codec, WebCrypto signers, codecs, and Codama program clients, which open the HTTP and WebSocket transports to the Solana JSON-RPC node. The class API is unchanged, and everything under it is Kit.A layered stack of the web3.js v3 architecture. The top layer is the classes your code calls, Connection, PublicKey, Keypair, and Transaction, with the same names as v1. Every layer below is Solana Kit: typed RPC and subscriptions, the address codec, WebCrypto signers, codecs, and Codama program clients, which open the HTTP and WebSocket transports to the Solana JSON-RPC node. The class API is unchanged, and everything under it is Kit.

Connection​

In v1, Connection carried its own networking stack. It sent JSON-RPC requests through the jayson client, validated responses with superstruct schemas, and ran subscriptions over rpc-websockets. Each RPC method had a hand-written response schema, and new RPC fields often lagged behind the protocol.

In v3, the constructor builds a Kit RPC client with createSolanaRpcApi over a Kit HTTP transport, and methods like getBalance and getAccountInfo forward to it. Subscription methods like onAccountChange go through a Kit websocket runtime. That runtime merges identical listeners into a single server subscription, and it caps each websocket channel at 100 subscriptions, which is Kit's default. The ConnectionConfig options you already pass, such as httpHeaders, fetch, and fetchMiddleware, still work because v3 hands them to the Kit transport.

Responses now come back in Kit's types. Every u64 value is a bigint, and most arrays are readonly. The default commitment also comes from Kit, which is why it changed from finalized to confirmed.

PublicKey​

A v1 PublicKey stored its value as a BN.js big number and encoded it with the bs58 package. A v3 PublicKey stores 32 bytes and uses Kit's address codec for base58. Its toBase58() method returns Kit's Address type, a branded string, so you can pass the result to any Kit function without a cast.

The class also implements Kit's HasAddress interface, which is any object with an address property. Codama-generated instruction builders accept HasAddress for account fields, so you can pass a PublicKey to them directly. PDA derivation moved to Kit's getProgramDerivedAddress, which hashes with WebCrypto's SHA-256. WebCrypto is async, so PDA derivation is async too.

Keypair​

A v1 Keypair held raw secret key bytes and signed synchronously with a pure JavaScript ed25519 library. A v3 Keypair wraps a Kit KeyPairSigner, which holds a WebCrypto CryptoKeyPair. WebCrypto only offers async APIs for importing keys and signing, so generate(), fromSecretKey(), and fromSeed() all return promises.

Because Keypair implements KeyPairSigner, it has the address, signMessages(), and signTransactions() members that Kit expects from a signer. Any Kit function or generated client that asks for a signer accepts a v3 Keypair as is. The publicKey and secretKey getters still exist for web3.js code.

Transaction and VersionedTransaction​

The transaction classes keep the same fields: instructions, feePayer, recentBlockhash, and signatures. When you sign, v3 compiles the message and passes it to Kit's partiallySignTransactionWithSigners, which calls each signer's signTransactions(). That is why sign() and partialSign() are async. serialize() is async because it verifies every signature by default before it returns bytes, and verification also runs on WebCrypto.

Transaction.add() learned a new input type. Alongside TransactionInstruction, it accepts Kit instruction objects and Kit instruction plans, and converts them on the way in. That conversion lets you mix Kit program clients into existing v1 style code.

Program Helpers​

SystemProgram, StakeProgram, ComputeBudgetProgram, and AddressLookupTableProgram keep their v1 method names, so SystemProgram.transfer() still exists. v1 encoded instruction data with hand-written BufferLayout definitions. v3 encodes it with Codama-generated clients from the @solana-program/* packages, vendored into web3.js. Decoded amounts from these helpers are bigint.

web3.js v1 vs Kit vs web3.js v3​

The three libraries make different trade-offs between familiarity and internals:

web3.js v1Solana Kitweb3.js v3
API styleClass-basedFunctional, composableClass-based, same names as v1
Internalsjayson, superstruct, BufferLayout, BN.jsTyped RPC, codecs, WebCryptoKit
Numbers from RPCnumberbigintbigint
Key generation and signingSyncAsyncAsync
Program clientsHand-written helpersCodama-generated clientsCodama clients behind the v1 helper names
Tree shakingPoorFullBetter than v1
Migration from v1Not applicableRewriteMechanical edits
MaintenanceCritical fixes onlyActiveActive

Kit and v3 share the same internals, so they match on number types, async crypto, and maintenance, and v1 is the outlier on each of those rows. Where Kit and v3 differ, the difference comes from the API on top.

Kit splits everything into small functions, so a bundler can drop every function you never import. v3 exposes classes, and a class pulls in all of its methods whether you call them or not. An app that imports Connection ships the code for every RPC method Connection wraps. v3 bundles are smaller than v1 because the old dependencies are gone, but they will not shrink as far as a Kit app.

Here is the same SOL transfer in v1 and v3.

web3.js v1:

transfer-v1.ts
import {
Connection,
Keypair,
LAMPORTS_PER_SOL,
SystemProgram,
Transaction,
sendAndConfirmTransaction,
} from '@solana/web3.js';

const connection = new Connection('http://127.0.0.1:8899', 'confirmed');

const payer = Keypair.generate();
const recipient = Keypair.generate();

const airdrop = await connection.requestAirdrop(payer.publicKey, LAMPORTS_PER_SOL);
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
await connection.confirmTransaction({ signature: airdrop, blockhash, lastValidBlockHeight });

const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: recipient.publicKey,
lamports: 0.1 * LAMPORTS_PER_SOL,
}),
);

const signature = await sendAndConfirmTransaction(connection, transaction, [payer]);
console.log('signature:', signature);

const balance = await connection.getBalance(recipient.publicKey);
console.log('recipient balance:', balance, typeof balance);

web3.js v3:

transfer-v3.ts
import {
Connection,
Keypair,
LAMPORTS_PER_SOL,
SystemProgram,
Transaction,
sendAndConfirmTransaction,
} from '@solana/web3.js';

const connection = new Connection('http://127.0.0.1:8899', 'confirmed');

// Key generation is async in v3
const payer = await Keypair.generate();
const recipient = await Keypair.generate();

const airdrop = await connection.requestAirdrop(payer.publicKey, LAMPORTS_PER_SOL);
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash();
await connection.confirmTransaction({ signature: airdrop, blockhash, lastValidBlockHeight });

const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: recipient.publicKey,
lamports: 0.1 * LAMPORTS_PER_SOL,
}),
);

const signature = await sendAndConfirmTransaction(connection, transaction, [payer]);
console.log('signature:', signature);

// getBalance returns bigint in v3
const balance = await connection.getBalance(recipient.publicKey);
console.log('recipient balance:', balance, typeof balance);

The v1 and v3 files differ in two await keywords and the type of the last line:

recipient balance: 100000000 number <- v1
recipient balance: 100000000n bigint <- v3

Install web3.js v3​

Install the v3 line explicitly, along with the Kit packages the interop example uses.

npm install @solana/web3.js@3 @solana/kit @solana-program/system

The examples use top-level await, so set "type": "module" in package.json and run them with npx tsx. Start solana-test-validator in a separate terminal before running them.

If the project uses @solana/web3-compat, remove it. That package was an interim shim for running the v1 API on Kit, and v3 replaces it.

Fix the Breaking Changes​

Run your project's typecheck right after the install. Most of the changes below fail to compile, which gives you a list of call sites to work through. The ones that compile but behave differently are called out as you go.

Key Generation and PDA Derivation Are Async​

Keypair wraps a WebCrypto key pair, and WebCrypto imports keys asynchronously. The constructor is private, and every factory returns a promise. The sync PDA helpers are gone too.

v1
const payer = Keypair.generate();
const restored = Keypair.fromSecretKey(secretKey);
const [pda, bump] = PublicKey.findProgramAddressSync(
[Buffer.from('vault'), payer.publicKey.toBuffer()],
programId,
);
v3
const payer = await Keypair.generate();
const restored = await Keypair.fromSecretKey(secretKey);
const [pda, bump] = await PublicKey.findProgramAddress(
[new TextEncoder().encode('vault'), payer.publicKey.toBytes()],
programId,
);

Signing, Serialization, and Verification Are Async​

Signing goes through Kit's partiallySignTransactionWithSigners, so Transaction.sign(), partialSign(), serialize(), and verifySignatures() all return promises. VersionedTransaction.sign() is async as well, while VersionedTransaction.serialize() stays sync.

v1
transaction.sign(payer);
const wire = transaction.serialize();
await connection.sendRawTransaction(wire);
v3
await transaction.sign(payer);
const wire = await transaction.serialize();
await connection.sendRawTransaction(wire);

serialize() now returns a Uint8Array rather than a Buffer. If you pass it to code that still wants a Buffer, wrap it at that boundary with Buffer.from(wire) instead of carrying Buffer through the app.

Signers Must Be Kit Signers​

The v1 Signer interface was a plain { publicKey, secretKey } object. v3 removes it. The exported Signer type is now Kit's MessagePartialSigner & TransactionPartialSigner, and every signing API accepts any Kit TransactionPartialSigner. A v3 Keypair implements that interface, so pass keypairs directly.

v1
const signer = { publicKey: keypair.publicKey, secretKey: keypair.secretKey };
await sendAndConfirmTransaction(connection, transaction, [signer]);
v3
await sendAndConfirmTransaction(connection, transaction, [keypair]);

If you stored signers as { publicKey, secretKey } literals, rebuild them with await Keypair.fromSecretKey(signer.secretKey). Wallet adapters and hardware signers that implement Kit's TransactionPartialSigner pass straight through without a wrapper. Signers that only sign messages are rejected at runtime.

Keypair gained an address property to satisfy Kit's signer interface. It returns a branded base58 string, not a PublicKey. Keep using keypair.publicKey in web3.js code, and pass the Keypair itself when a Kit API wants a signer.

The Default Commitment Is Now confirmed​

A Connection created without a commitment used finalized in v1. v3 matches Kit and uses confirmed. This change compiles cleanly, so it is the one most likely to reach production unnoticed.

// v1: reads at finalized. v3: reads at confirmed.
const connection = new Connection(process.env.QUICKNODE_ENDPOINT!);

// Same behavior in both versions
const finalizedConnection = new Connection(process.env.QUICKNODE_ENDPOINT!, 'finalized');

If any code depends on finality, for example a payment checker or an indexer that must never see a rolled-back block, set the commitment explicitly on the Connection or on each call. The sendAndConfirmTransaction helper still falls back to finalized when neither the options nor the connection specify one, so transaction confirmation is unchanged.

RPC Numbers Are bigint​

Kit types every 64-bit field as bigint, and v3 passes that through. Lamports, slots, block heights, timestamps, epochs, and context.slot all come back as bigint. Blockhashes and nonces are branded Blockhash strings.

const balance = await connection.getBalance(owner); // bigint
const slot = await connection.getSlot(); // bigint
const { lastValidBlockHeight } = await connection.getLatestBlockhash(); // bigint

Three places break:


  • Arithmetic between a bigint result and a number throws TypeError: Cannot mix BigInt and other types. Convert one side on purpose, for example balance - BigInt(fee).
  • JSON.stringify throws Do not know how to serialize a BigInt. Pass a replacer that converts to a string, or stringify at the API boundary rather than in shared code.
  • Local types declared as number for a slot or a lamport amount no longer match. Widen them to number | bigint, or derive them from the SDK's types.

Keep bigint through your application state and convert with Number() only where a display library or external API needs it. A balance above Number.MAX_SAFE_INTEGER lamports (about 9 million SOL) loses precision as a number.

Account Data Is Uint8Array and Results Are Readonly​

getAccountInfo and its batch variants return AccountInfo<Uint8Array> with a space: bigint field, and the whole object is wrapped in Readonly. Several RPC arrays are readonly too, including the results of getRecentPrioritizationFees, getVoteAccounts, and getLargestAccounts.

v1
const info = await connection.getAccountInfo(owner);
const data: Buffer = info!.data;
const fees = await connection.getRecentPrioritizationFees();
fees.sort((a, b) => a.slot - b.slot);
v3
const info = await connection.getAccountInfo(owner);
const data: Uint8Array = info!.data;
const fees = await connection.getRecentPrioritizationFees();
const sorted = [...fees].sort((a, b) => Number(a.slot - b.slot));

Codama codecs and most modern decoders take Uint8Array directly. If a decoder you depend on still requires Buffer, convert at that call with Buffer.from(info.data). Spread or clone readonly arrays before sorting or pushing.

Removed APIs​

These have no deprecation bridge in v3. The compiler reports each one as a missing export or property, so they are the easiest changes to find.

Removed in v3What it wasReplacement
PublicKey.unique()Returned a new key on each call, mostly for testsA local helper that passes 32 random bytes to new PublicKey()
Account classDeprecated keypair wrapper from early v1Keypair
getRecentBlockhash(), getRecentBlockhashAndContext(), getFeeCalculatorForBlockhash(), FeeCalculatorWrapped RPC methods that Agave removedgetLatestBlockhash() for a blockhash, getFeeForMessage() for a fee estimate
BN.js input to the PublicKey constructorPublicKey no longer stores a BNA base58 string, Uint8Array, number array, or another PublicKey
BufferLayout layoutsv1 encoding for program instructions and account stateCodama codecs from the matching @solana-program/* package
bs58 dependencyInstalled transitively by web3.jsAdd bs58 to your own package.json, or use getBase58Codec() from Kit

v3 also changes two behaviors. getMinimumBalanceForRentExemption now rejects on RPC failure instead of logging a warning and resolving to zero. Transaction.add() checks instanceof Transaction and instanceof TransactionInstruction rather than duck-typing, so it rejects hand-built objects that only look like instructions.

Use Kit Clients from web3.js v3​

Because the two libraries share types, you can adopt Kit piece by piece inside v3 code. PublicKey converts to and from Kit's Address, a Keypair is a Kit signer, and Transaction.add() accepts Kit instructions, as the architecture section above covers.

This script sends a transfer built with the Codama-generated System program client through a v3 Connection:

interop.ts
import {
Connection,
Keypair,
LAMPORTS_PER_SOL,
PublicKey,
Transaction,
sendAndConfirmTransaction,
} from '@solana/web3.js';
import { getTransferSolInstruction, SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system';
import { isKeyPairSigner, lamports, type Address } from '@solana/kit';

const connection = new Connection('http://127.0.0.1:8899', 'confirmed');
const payer = await Keypair.generate();
const recipient = await Keypair.generate();

const airdrop = await connection.requestAirdrop(payer.publicKey, LAMPORTS_PER_SOL);
await connection.confirmTransaction({ signature: airdrop, ...(await connection.getLatestBlockhash()) });

// A v3 Keypair satisfies Kit's signer interface
console.log('isKeyPairSigner:', isKeyPairSigner(payer));

// PublicKey to Address and back
const address: Address = recipient.publicKey.toBase58();
const roundTrip = new PublicKey(address);
console.log('round trip:', roundTrip.equals(recipient.publicKey));
console.log('system program:', new PublicKey(SYSTEM_PROGRAM_ADDRESS).toBase58());

// A Codama-generated instruction goes straight into Transaction.add
const transaction = new Transaction().add(
getTransferSolInstruction({
source: payer,
destination: recipient.publicKey,
amount: lamports(100_000_000n),
}),
);
const signature = await sendAndConfirmTransaction(connection, transaction, [payer]);
console.log('signature:', signature);
console.log('recipient balance:', await connection.getBalance(recipient.publicKey));

Run it:

npx tsx interop.ts

Expected output:

isKeyPairSigner: true
round trip: true
system program: 11111111111111111111111111111111
signature: 4RTnMsXN5VTbyMheZHgMBARHZdRQQe9iMkKAjfhBtvn9urZi85iR1HH1tBz2ceUn8Rev4P4cr2maDiJCEaSvQhKJ
recipient balance: 100000000n

The source field on getTransferSolInstruction is typed TransactionSigner, and passing the Keypair is what marks that account as a signer in the instruction. The signature itself still happens inside sendAndConfirmTransaction. Use the same pattern for any @solana-program/* client. Build the instruction with Kit and send it with web3.js.

Run the Migration​

For a codebase of any size, work through the changes in this order:

  1. Typecheck. Install v3 and run tsc --noEmit. The errors cover the removed APIs, the private Keypair constructor, missing await on serialize(), Buffer typed account data, number typed balances, and the old signer shape. Fix the shared boundary first, such as a wrapper module that re-exports web3.js types, so downstream errors shrink.
  2. Search for what the compiler misses. These compile in v3 but behave differently:
    • new Connection(url) with no commitment, if the code relies on finalized
    • .sign() and .partialSign() calls without await
    • JSON.stringify on anything that includes an RPC result
    • Number() calls and arithmetic on slots, balances, and block heights
    • Buffer.from(), Buffer.alloc(), and Buffer.concat() calls on data that flows into signing or hashing
  3. Run against a real node. Mocked tests pass with stale number fixtures and the old commitment default. Point the migrated code at a local validator or your Quicknode devnet endpoint and exercise one transaction send and one account read end to end.

Use the Migration Skill​

The web3.js repository includes an agent skill for this migration. A skill is a SKILL.md file that a coding agent loads when your prompt matches its description, so asking the agent to "migrate this web3.js v1 app to v3" or "find bigint breakages after upgrading web3.js" pulls in the migration rules before the agent touches any code. Install it into your project:

npx skills add https://github.com/solana-foundation/solana-web3.js/tree/main/skills/web3js-v1-to-v3-migration

The skill starts with a triage pass. The agent runs regex searches for every v1 pattern that breaks in v3, including the removed APIs, sync Keypair and PDA calls, Buffer usage, arithmetic on slots and block heights, and .sort() or .push() on RPC results. If the search finds @solana/spl-token imports, the agent also loads a second reference file that maps each @solana/spl-token call to @solana-program/token.

The agent then fixes the code in slices, in a fixed order. It starts at the narrowest shared boundary, such as a module that re-exports web3.js types, then removes deleted APIs, converts key handling and signing to async, adds explicit commitments and bigint handling to Connection call sites, replaces Buffer with Uint8Array, and clones readonly results before mutating them. After each slice it runs the narrowest test that covers that code before moving on.

The skill also tells the agent when it is done. Its completion checklist ends with a project typecheck and one integration run that sends a transaction and reads an account, the same check as step 3 above. Check the commitment changes in the agent's diff yourself, since only you know which flows need finalized reads.

Wrapping Up​

web3.js v3 puts the v1 classes on top of Kit's RPC, signers, codecs, and generated program clients, which is why most of your migration is adding await and widening types. You ran the same transfer in v1 and v3, worked through each breaking change with the compiler as your checklist, and sent a Codama-built instruction through a v3 Connection. Start your own migration with a typecheck, then run the commitment and bigint searches that the compiler can't do for you.

Resources​


Frequently Asked Questions​

What is the difference between web3.js v3 and Solana Kit?

web3.js v3 is a class-based API built on top of Solana Kit. Kit exposes a functional, composable API with typed RPC, codecs, WebCrypto signers, and Codama-generated program clients. v3 wraps those same packages behind the Connection, PublicKey, Keypair, and Transaction classes from v1. Choose Kit for new code that wants the smallest bundles and the full functional API; choose v3 to upgrade an existing v1 codebase without a rewrite.

Is web3.js v1 still maintained?

The v1 line receives critical fixes only. New features and RPC coverage land in v3, which is built on the actively maintained Solana Kit packages.

Do I have to rewrite my code to upgrade to web3.js v3?

No. v3 keeps the Connection, PublicKey, Keypair, and Transaction classes from v1. Most of the work is adding await to key generation and signing, accepting bigint for lamports and slots, and replacing Buffer with Uint8Array. The compiler flags most of these changes.

Did the default commitment change in web3.js v3?

Yes. A Connection created without a commitment used finalized in v1 and uses confirmed in v3, matching Kit's default. The change compiles without errors, so set commitment: 'finalized' explicitly on the Connection or on individual calls where finality matters.

Can I use @solana/spl-token with web3.js v3?

No. @solana/spl-token depends on v1 types. Use the Codama-generated @solana-program/token client, or @solana-program/token-2022 for Token-2022 mints. Its instructions pass directly into Transaction.add(), and a v3 Keypair works as the signer for fields typed TransactionSigner.