web3js-nextjs
Next.js starter built on @solana/web3.js v3 and @solana/wallet-adapter v3. Connect a browser wallet, switch networks, sign in with Solana (SIWS 1.1), and send real transactions — SOL transfers and SPL token actions — with the familiar Connection / Transaction / PublicKey API.
Getting Started
Requires Node.js 24 or newer.
npx -y create-solana-dapp@latest -t solana-foundation/templates/web3js/web3js-nextjsSign-in needs a session secret. Copy the example env file and fill in AUTH_SECRET. In production also set APP_URL to the app's public origin (for example https://app.example.com); development falls back to the request host.
cp .env.example .env.local
openssl rand -base64 32 # paste the output into AUTH_SECRETnpm install
npm run devOpen http://localhost:3000, connect a wallet, and (on devnet) click Airdrop 1 SOL to fund it. Then try the actions. Need devnet SOL another way? faucet.solana.com.
To use localnet, start a local validator in another terminal before selecting localnet. Only wallets that advertise the solana:localnet chain are listed there.
solana-test-validatorThe public mainnet RPC endpoint rejects most browser traffic. Set NEXT_PUBLIC_MAINNET_RPC_URL in .env.local to a provider endpoint before using mainnet.
What's Included
- Wallet connection with
@solana/wallet-adapterv3 —ConnectionProvider,WalletProvider,WalletModalProvider, and a themedWalletMultiButton(Wallet Standard discovery, auto-reconnect) - Network switcher — devnet, testnet, mainnet, localnet
- Live SOL balance via
connection.getBalance+connection.onAccountChange, and a devnet/testnet airdrop - Transfer SOL with
SystemProgram.transferin a legacyTransaction - Sign message with
signMessage, verified locally withpublicKey.verifySignature - SPL token actions with
@solana-program/token— create a mint, mint to your associated token account, mint more, and transfer to any wallet (recipient ATA created idempotently), with balances decoded bygetTokenDecoder/getMintDecoder - Sign In With Solana — server-issued nonce, wallet
signIn, server-side verification, and an HMAC-signed httpOnly session cookie - Toast notifications with explorer links, Tailwind CSS v4 with light/dark mode
How it works
Providers
app/components/solana-provider.tsx wires the wallet adapter to the selected cluster:
<ConnectionProvider endpoint={getClusterUrl(cluster)}>
<WalletProvider chain={getWalletChain(cluster)} onError={onWalletError}>
<WalletModalProvider>{children}</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>Both props are plain strings looked up from app/lib/cluster.ts, so they stay referentially stable between renders. Changing chain rebuilds the wallet client; localnet uses solana:localnet so wallets that broadcast transactions themselves send them to the local validator at http://localhost:8899.
Components use useConnection() for the v3 Connection and useWallet() for publicKey, signer, sendTransaction, signMessage, and signIn.
Sending transactions
app/lib/hooks/use-send-transaction.tsx fetches a blockhash, calls sendTransaction(transaction, connection, { minContextSlot, signers }), confirms with the blockhash strategy, and shows a toast. Extra local signers (such as a new mint keypair) go in signers and sign before the wallet does.
SPL tokens on web3.js v3
v3 Transaction.add(...) accepts Kit instructions and instruction plans, so @solana-program/token builders drop straight in — no @solana/spl-token. See app/components/actions/token-card.tsx:
const newMint = await Keypair.generate();
const transaction = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: publicKey,
newAccountPubkey: newMint.publicKey,
lamports: await connection.getMinimumBalanceForRentExemption(space),
space,
programId: new PublicKey(TOKEN_PROGRAM_ADDRESS),
}),
getInitializeMint2Instruction({
mint: newMint.publicKey,
decimals,
mintAuthority: publicKey.toBase58(),
freezeAuthority: null,
}),
await getMintToATAInstructionPlanAsync({
payer: signer,
owner: publicKey.toBase58(),
mint: newMint.publicKey.toBase58(),
mintAuthority: signer,
amount,
decimals,
})
);
await sendTransaction(transaction, connection, { signers: [newMint] });The rules of thumb at the boundary:
- Builder account inputs take a v3
PublicKeydirectly; fields typed as a bareAddresstakepublicKey.toBase58(). - Signer-typed fields (
payer,mintAuthority,authority) takeuseWallet().signerso the instruction marks the wallet as a signer. The wallet still signs once, throughsendTransaction. new PublicKey(kitAddress)converts a Kit address back forSystemProgramandConnectioncalls.- Token balances come from
connection.getAccountInfo(...)decoded withgetTokenDecoder()/getMintDecoder(); amounts arebigint.
Sign In With Solana
The flow lives in app/components/auth-context.tsx (client) and app/api/auth (server):
- The client requests a nonce from
GET /api/auth/nonce. - It builds a SIWS input (domain, address, statement, URI, nonce,
issuedAt) and calls the wallet'ssignIn(input). WhenuseWallet().supportsSignInWithOffchainMessageis true (wallets implementingsolana:signIn1.1.0), it passesuseOffchainMessage: { messageVersion: 1 }so the wallet signs a version 1 off-chain message, which hardware wallets can display. - The client posts the input and output to
POST /api/auth/verify. - The server verifies with
verifySignInRequestand, on success, sets a session cookie. GET /api/auth/sessionreads the session;DELETE /api/auth/sessionsigns out. The client also signs out when the wallet settles as disconnected or switches accounts, but not while it reconnects after a page load or cluster switch.
What the server verifies before issuing a session:
input.domainequals the host ofAPP_URL. The client signswindow.location.host, soAPP_URLmust be the exact origin users visit. WithoutAPP_URL, development uses the request'sHostheader and production refuses to verify, because a proxy or a phishing backend can setHostto anything.issuedAtis no older than five minutes (and not more than a minute in the future);expirationTime/notBeforeare honored when present.- The signature is 64 bytes and the public key 32 bytes, and
verifySignIn(input, output)from@solana/wallet-adapter/corepasses: the account's public key matches its address, the signed message reproduces every input field and namesinput.address, and it carries a valid signature from that account, for both plain-text and off-chain message formats. - The nonce was issued by this server, has not expired, and is consumed so it cannot be replayed.
POST /api/auth/verifyandDELETE /api/auth/sessionreject requests whoseOriginheader differs from the app origin.
The session cookie is httpOnly, SameSite=Lax, Secure in production, and expires after 24 hours. Its value is base64url(JSON) + "." + HMAC-SHA256(AUTH_SECRET), so the server can trust it without a database. Rotating AUTH_SECRET invalidates every session.
Trust boundaries and demo limitations:
- Everything the client sends is untrusted; only the server checks above decide whether a session is issued. Client-side state (the signed-in panel) is a convenience, not an authorization check — protect your own API routes by reading the cookie with
decodeSession. - The nonce store in
app/lib/auth/nonce-store.tsis an in-memoryMapfor demo purposes. Nonces are lost on restart and are not shared between server instances or serverless invocations. Use a shared store with an atomic consume (RedisGETDEL, a database row delete) in production. - Sessions are stateless, so signing out clears the cookie but cannot revoke a token copied elsewhere before it expires. Add a server-side session table if you need revocation.
- Signing in proves wallet ownership only; it never requests a transaction signature or moves funds.
Testing
npm run testThe Vitest suite in tests/ covers the pure logic without any network: amount parsing and formatting, and the SIWS server checks — signed with a real generated Keypair in both plain-text and off-chain message formats — including replayed and unknown nonces, wrong domains, stale timestamps, tampered signatures, mismatched inputs, forged account addresses, and session token tampering and expiry.
npm run ci runs the build, typecheck, lint, format check, and tests.
Coming from web3.js v1
@solana/web3.js v3 keeps the class-based API but runs on Solana Kit internally. The changes you are most likely to hit in app code: Keypair.generate(), signing, and PDA derivation are async; lamports, slots, and block heights are bigint; account data is Uint8Array; @solana/spl-token is replaced by @solana-program/token; and the wallet adapter is one package with Wallet Standard discovery instead of per-wallet adapters. See:
