An open-source, learn-by-example scaffold for @privy-io/react-auth on Solana mainnet. This repo is the answer to "show me working code, today." Clone it, plug in your app ID, and you have a real embedded wallet you can sign messages with and send SOL from in two clicks.
bun install
cp .env.example .env.local
# Set NEXT_PUBLIC_PRIVY_APP_ID — grab it from https://dashboard.privy.io
bun run devOpen http://localhost:3000. Log in (email / Twitter / external wallet — all three are enabled by default). An embedded Solana wallet is auto-created on first login regardless of how you logged in. The page lets you check balance, sign an arbitrary message, and transfer SOL with a memo.
The Privy v3 surface for Solana is hook-based — you do NOT call wallet.signMessage(...) like older guides show. Pass the object into a hook instead.
import { type PrivyClientConfig } from '@privy-io/react-auth';
import { toSolanaWalletConnectors } from '@privy-io/react-auth/solana';
import { createSolanaRpc, createSolanaRpcSubscriptions } from '@solana/kit';
export const PRIVY_CONFIG: PrivyClientConfig = {
loginMethods: ['email', 'twitter', 'wallet'],
appearance: { walletChainType: 'solana-only' },
externalWallets: {
solana: { connectors: toSolanaWalletConnectors() },
},
embeddedWallets: {
solana: { createOnLogin: 'all-users' },
},
solana: {
rpcs: {
'solana:mainnet': {
rpc: createSolanaRpc(process.env.NEXT_PUBLIC_SOLANA_RPC_URL!),
rpcSubscriptions: createSolanaRpcSubscriptions(process.env.NEXT_PUBLIC_SOLANA_WSS_URL!),
},
},
},
};Things that bite people here:
- Here
rpcandrpcSubscriptionsmatter. Confirmations come over WSS; if the WSS endpoint is broken, the broadcast still works but the SDK can't track confirmation and you'll see WebSocket errors in the console. Not applicable if you have in-house sending and confirmation service. embeddedWallets.solana.createOnLogin: 'all-users'creates a Solana embedded wallet for every logged-in user, even if they already connected an external wallet. This scaffold uses'all-users'so the demo always has an embedded wallet to sign from. For a real app,'users-without-wallets'is usually more appropriate — it respects the user's existing wallet and only creates an embedded one when they don't have any.walletChainType: 'solana-only'filters the connect-modal so EVM wallets don't appear.
The hook is useWallets from @privy-io/react-auth/solana. The embedded wallet's name is 'Privy', exposed on the inner Wallet-Standard object.
src/hooks/use-embedded-solana-wallet.ts:
import { useWallets } from '@privy-io/react-auth/solana';
import { useMemo } from 'react';
export function useEmbeddedSolanaWallet() {
const { wallets, ready } = useWallets();
const wallet = useMemo(() => {
if (!ready) return null;
return wallets.find((w) => w.standardWallet.name === 'Privy') ?? wallets[0] ?? null;
}, [wallets, ready]);
return { wallet, ready };
}This works regardless of login method — email, Twitter, Google, passkey or an external wallet. The embedded wallet is created at login, not at connect.
import { useSignMessage } from '@privy-io/react-auth/solana';
import bs58 from 'bs58';
const { signMessage } = useSignMessage();
const { signature } = await signMessage({
message: new TextEncoder().encode('hello world'),
wallet, // from useEmbeddedSolanaWallet()
});
// signature is a Uint8Array. Encode for display / transport:
console.log(bs58.encode(signature));See: src/components/sign-message-card.tsx.
Use this when you want the signed bytes back — to simulateTransaction first, batch with another signer, or hand off to a relayer.
import { useSignTransaction } from '@privy-io/react-auth/solana';
import { compileTransaction, getTransactionEncoder } from '@solana/kit';
const { signTransaction } = useSignTransaction();
const encoded = new Uint8Array(getTransactionEncoder().encode(compileTransaction(message)));
const { signedTransaction } = await signTransaction({
transaction: encoded,
wallet,
});
// signedTransaction is a Uint8Array — pass to rpc.sendTransaction() or a relayer.Build the transaction with @solana/kit + @solana-program/*, encode as bytes, hand to Privy:
import { useSignAndSendTransaction } from '@privy-io/react-auth/solana';
import {
address,
appendTransactionMessageInstructions,
compileTransaction,
createNoopSigner,
createTransactionMessage,
getTransactionEncoder,
pipe,
setTransactionMessageFeePayer,
setTransactionMessageLifetimeUsingBlockhash,
} from '@solana/kit';
import { getTransferSolInstruction } from '@solana-program/system';
import { getAddMemoInstruction } from '@solana-program/memo';
import bs58 from 'bs58';
const { signAndSendTransaction } = useSignAndSendTransaction();
const latestBlockhash = (await rpc.getLatestBlockhash().send()).value;
const feePayer = createNoopSigner(address(wallet.address));
const compiled = pipe(
createTransactionMessage({ version: 0 }),
(tx) => setTransactionMessageFeePayer(address(wallet.address), tx),
(tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx),
(tx) =>
appendTransactionMessageInstructions(
[
getTransferSolInstruction({
source: feePayer,
destination: address('<recipient>'),
amount: 1_000_000n, // lamports
}),
getAddMemoInstruction({ memo: 'gm' }),
],
tx,
),
compileTransaction,
);
const encoded = new Uint8Array(getTransactionEncoder().encode(compiled));
const { signature } = await signAndSendTransaction({
transaction: encoded,
wallet,
chain: 'solana:mainnet',
});
// signature is a Uint8Array → bs58.encode() for explorer links.
console.log(`https://solscan.io/tx/${bs58.encode(signature)}`);Three things to watch:
transactionmust beUint8Arrayof a compiled@solana/kittransaction. Privy does not accept@solana/web3.jsTransaction/VersionedTransactionobjects, base64 strings, or the message beforecompileTransaction.sourceongetTransferSolInstructiontakes aTransactionSigner, not anAddress. UsecreateNoopSigner(address(wallet.address))— Privy injects the real signature server-side.chainis CAIP-2 ('solana:mainnet'), and must match a key undersolana.rpcsin the provider config.
See: src/components/transfer-card.tsx.
These cost real time during development. Captured so you don't repeat them.
- Solana
useWallets≠ rootuseWallets. Always import from@privy-io/react-auth/solana. The root export returns EVM wallets. - All sign hooks wrap the result.
{ signature }and{ signedTransaction }, not raw bytes. Destructure. bun run buildis pinned to--webpack. Next 16 defaultsnext buildto Turbopack, which doesn't honor the@solana/kitexternals shim innext.config.ts.bun run devstill uses Turbopack so HMR stays fast.
src/
├── app/
│ ├── layout.tsx # fonts + no-flash theme script
│ ├── page.tsx # demo composition
│ ├── providers.tsx # PrivyProvider wrapper
│ └── globals.css # theme tokens (dark/light)
├── components/
│ ├── login-button.tsx # auth UI
│ ├── wallet-card.tsx # address + balance + refresh
│ ├── sign-message-card.tsx # useSignMessage example
│ ├── transfer-card.tsx # useSignAndSendTransaction example
│ ├── status-banner.tsx
│ ├── theme-toggle.tsx
│ └── ui/ # button, input, textarea, card, code
├── hooks/
│ ├── use-embedded-solana-wallet.ts
│ ├── use-solana-balance.ts
│ └── use-theme.ts
└── lib/
├── privy-config.ts # PrivyClientConfig
├── solana.ts # RPC client + helpers
├── explorer.ts # Solscan URL builders
└── format.ts # address / lamport utils
| Variable | Required | Default |
|---|---|---|
NEXT_PUBLIC_PRIVY_APP_ID |
Yes | — |
NEXT_PUBLIC_SOLANA_RPC_URL |
No | https://api.mainnet-beta.solana.com |
NEXT_PUBLIC_SOLANA_WSS_URL |
No | wss://api.mainnet-beta.solana.com |
The public RPC throttles aggressively and the public WSS frequently refuses connections. For anything beyond a demo, set both to a Helius / Birdeye / Quicknode URL (each provider exposes both on the same key).
| Script | Purpose |
|---|---|
bun run dev |
Next.js dev server (Turbopack) |
bun run build |
Production build (Webpack) |
bun run start |
Run production build |
bun run lint |
ESLint |
bun run typecheck |
tsc --noEmit |
bun run format |
Prettier write |
bun run format:check |
Prettier check |
MIT. Use it, ship it, fork it.