Skip to content

Repository files navigation

Privy v3 + Solana — Scaffold

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.

Quick start

bun install
cp .env.example .env.local
# Set NEXT_PUBLIC_PRIVY_APP_ID — grab it from https://dashboard.privy.io
bun run dev

Open 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.


Using Privy's Solana hooks

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.

1. Configure PrivyProvider

src/lib/privy-config.ts:

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 rpc and rpcSubscriptions matter. 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.

2. Get the embedded wallet (any login method)

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.

3. useSignMessage — sign arbitrary bytes

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.

4. useSignTransaction — sign without sending

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.

5. useSignAndSendTransaction — the common path

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:

  • transaction must be Uint8Array of a compiled @solana/kit transaction. Privy does not accept @solana/web3.js Transaction / VersionedTransaction objects, base64 strings, or the message before compileTransaction.
  • source on getTransferSolInstruction takes a TransactionSigner, not an Address. Use createNoopSigner(address(wallet.address)) — Privy injects the real signature server-side.
  • chain is CAIP-2 ('solana:mainnet'), and must match a key under solana.rpcs in the provider config.

See: src/components/transfer-card.tsx.


Gotchas

These cost real time during development. Captured so you don't repeat them.

  • Solana useWallets ≠ root useWallets. 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 build is pinned to --webpack. Next 16 defaults next build to Turbopack, which doesn't honor the @solana/kit externals shim in next.config.ts. bun run dev still uses Turbopack so HMR stays fast.

Project structure

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

Environment

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).

Scripts

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

License

MIT. Use it, ship it, fork it.

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages