The piggy bank post covered the Anchor program — lock SOL, enforce a time constraint, unlock after expiration. But a program without a frontend is just code on a blockchain that nobody interacts with. This post adds the other half: a web app that talks to the on-chain program.
The live version is at piggybank.moviendo.me. Connect a wallet, lock some devnet SOL, wait a few minutes, unlock it.
The Stack
- Next.js — React framework, handles routing and SSR
- Anchor TS client — Type-safe interaction with the on-chain program
- Solana Wallet Adapter — Connect Phantom, Solflare, or any Solana wallet
- React Query — Manages server state, caching, and cache invalidation
The frontend is a single-page app. No backend, no database. The blockchain is the backend.
The Hard Part: Finding Lock Accounts
The piggy bank program doesn’t use PDAs. Each lock is a random keypair generated at creation time. There’s no deterministic way to derive a lock’s address from a user’s wallet.
This is the first design decision: how do you find a user’s locks?
Three options:
- Store lock addresses in localStorage — fragile, lost on browser clear
- getProgramAccounts with filters — queries the RPC node for all program accounts matching a pattern
- On-chain index via PDA — add an indexing account to the program itself
Option 2 works without modifying the program. The lock account stores a dst (destination) pubkey at byte offset 8 (after Anchor’s 8-byte discriminator). We can filter for all lock accounts where dst matches the connected wallet:
const accounts = await connection.getProgramAccounts(PROGRAM_ID, {
filters: [
{ dataSize: 48 }, // 8 discriminator + 32 dst + 8 exp
{
memcmp: {
offset: 8,
bytes: wallet.publicKey.toBase58(),
},
},
],
});
dataSize: 48 ensures we only get lock accounts (not other account types the program might create). memcmp at offset 8 matches the destination field against the connected wallet. The RPC node does the filtering server-side — we only get back the accounts we care about.
This approach has a cost: getProgramAccounts scans all accounts owned by the program. For a devnet demo this is fine. For production with thousands of accounts, you’d want an indexer or switch to PDAs.
Connecting to the Program
The Anchor TS client needs two things: a connection to the Solana cluster, and a wallet to sign transactions. The wallet adapter provides both:
export function useAnchorProvider() {
const { connection } = useConnection();
const wallet = useAnchorWallet();
return useMemo(() => {
if (!wallet) return null;
return new AnchorProvider(connection, wallet, {
commitment: "confirmed",
});
}, [connection, wallet]);
}
export function useProgram() {
const provider = useAnchorProvider();
return useMemo(() => {
if (!provider) return null;
return new Program<PiggyBank>(idl as PiggyBank, provider);
}, [provider]);
}
The IDL (Interface Description Language) is copied from the Anchor build output. It contains the program’s address, instruction signatures, and account layouts. The Program<PiggyBank> generic gives us full TypeScript autocomplete on method names and account fields.
One gotcha: useAnchorWallet() not useWallet(). The Anchor provider needs an object with publicKey and signTransaction — useWallet() returns a different shape that doesn’t satisfy the type.
Locking SOL
The lock instruction needs three accounts: the payer (wallet), a destination address, and a new lock account. The lock account is a random keypair generated per transaction:
export function useLockSol() {
const program = useProgram();
const wallet = useAnchorWallet();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ amountSol, expirationDate }) => {
if (!program || !wallet) throw new Error("Wallet not connected");
const lockKeypair = Keypair.generate();
const amountLamports = new BN(Math.floor(amountSol * LAMPORTS_PER_SOL));
const expTimestamp = new BN(Math.floor(expirationDate.getTime() / 1000));
const tx = await program.methods
.lock(amountLamports, expTimestamp)
.accounts({
payer: wallet.publicKey,
dst: wallet.publicKey,
lock: lockKeypair.publicKey,
})
.signers([lockKeypair])
.rpc();
return tx;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["lockAccounts"] });
},
});
}
Two things worth noting:
Two signers. The wallet signs as the payer, but the lock keypair also needs to sign because it’s being created as a new account via init. Anchor’s .signers([lockKeypair]) adds the second signature. The wallet adapter handles the first one automatically.
Destination is always the connected wallet. The program allows any destination address, but for this UI the user locks SOL and unlocks it back to themselves. Simpler UX, same mechanics.
Unlocking SOL
Unlock is simpler — the program just needs the lock account address:
export function useUnlockSol() {
const program = useProgram();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ lockAddress }) => {
if (!program) throw new Error("Wallet not connected");
const tx = await program.methods
.unlock()
.accounts({ lock: lockAddress })
.rpc();
return tx;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["lockAccounts"] });
},
});
}
Notice that .accounts() only passes lock. The program’s has_one = dst constraint tells Anchor to resolve the destination from the lock account’s stored dst field. Same for systemProgram — Anchor knows its address is fixed and fills it in. Less boilerplate on the client side.
React Query for Cache Invalidation
Both mutations call queryClient.invalidateQueries({ queryKey: ["lockAccounts"] }) on success. This tells React Query to refetch the lock list after a lock or unlock completes. Without this, the UI would show stale data until a manual refresh.
The query itself uses staleTime: 15_000 — lock data doesn’t change unless the user sends a transaction, so polling every 15 seconds is enough. For the unlock timer, the UI calculates expiration status client-side from the stored timestamp.
Next.js Gotchas
A few issues that came up with Solana libraries in Next.js:
Buffer polyfill. @solana/web3.js v1 uses Node’s Buffer which doesn’t exist in the browser. Fix: add globalThis.Buffer = Buffer in the provider component before anything else loads.
SSR and wallet state. Wallet connection state only exists in the browser. The wallet button component needs to be dynamically imported with ssr: false:
import dynamic from "next/dynamic";
const WalletMultiButton = dynamic(
() => import("@solana/wallet-adapter-react-ui").then(
(mod) => mod.WalletMultiButton
),
{ ssr: false }
);
Turbopack with webpack config. Next.js 16 defaults to Turbopack but still supports a webpack config for polyfill fallbacks. You need to add turbopack: {} to the config or the build fails with a confusing error about mismatched configs.
What I Learned
The Anchor TS client mirrors the program. Method names, account names, error types — they all come from the IDL. If the program compiles, the client types are correct. This is a much better developer experience than manually constructing transactions.
Account discovery is a real problem. Without PDAs, finding a user’s accounts requires scanning all program accounts. getProgramAccounts works but doesn’t scale. This is why most production programs use PDAs with deterministic seeds — you can derive the address client-side without querying.
Two-signer transactions need thought. The lock instruction requires both the wallet and a generated keypair to sign. This works fine in the browser because the wallet adapter handles the wallet signature and Anchor adds the keypair signature. But it’s a pattern you need to be aware of.
The blockchain is the backend. No API routes, no database migrations, no server state. The program stores data, the RPC node serves it, the wallet signs transactions. The frontend is pure UI.
Try it live at piggybank.moviendo.me — lock some devnet SOL and unlock it a few minutes later. The code is on GitHub: anchor-piggy-bank