Module 9: Frontend Patterns (React/NextJS)¶
Learning Objectives
By the end of this module, you will:
- Integrate Solana wallet adapters with React
- Build transactions with @solana/web3.js
- Generate and use Anchor program clients
- Implement state management for DApps
- Handle errors and create good UX patterns
- Deploy a NextJS frontend to Vercel
Prerequisites¶
Complete Module 8: DAO Governance first.
9.1 Wallet Adapter Setup¶
Installing Dependencies¶
pnpm add @solana/wallet-adapter-base \
@solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui \
@solana/wallet-adapter-wallets \
@solana/web3.js \
@coral-xyz/anchor
Wallet Provider Setup¶
Create a provider component that wraps your application:
// src/providers/SolanaProvider.tsx
"use client";
import { FC, ReactNode, useMemo } from "react";
import {
ConnectionProvider,
WalletProvider,
} from "@solana/wallet-adapter-react";
import { WalletModalProvider } from "@solana/wallet-adapter-react-ui";
import {
PhantomWalletAdapter,
SolflareWalletAdapter,
TorusWalletAdapter,
} from "@solana/wallet-adapter-wallets";
import { clusterApiUrl } from "@solana/web3.js";
// Import wallet adapter styles
import "@solana/wallet-adapter-react-ui/styles.css";
interface Props {
children: ReactNode;
}
export const SolanaProvider: FC<Props> = ({ children }) => {
// Use devnet for development, mainnet-beta for production
const endpoint = useMemo(
() => process.env.NEXT_PUBLIC_RPC_URL || clusterApiUrl("devnet"),
[]
);
// Configure supported wallets
const wallets = useMemo(
() => [
new PhantomWalletAdapter(),
new SolflareWalletAdapter(),
new TorusWalletAdapter(),
],
[]
);
return (
<ConnectionProvider endpoint={endpoint}>
<WalletProvider wallets={wallets} autoConnect>
<WalletModalProvider>{children}</WalletModalProvider>
</WalletProvider>
</ConnectionProvider>
);
};
Using in App Layout¶
// src/app/layout.tsx
import { SolanaProvider } from "@/providers/SolanaProvider";
import { QueryProvider } from "@/providers/QueryProvider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<QueryProvider>
<SolanaProvider>{children}</SolanaProvider>
</QueryProvider>
</body>
</html>
);
}
Wallet Connect Button¶
// src/components/WalletButton.tsx
"use client";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
export function WalletButton() {
const { publicKey, connected } = useWallet();
return (
<div className="flex items-center gap-4">
<WalletMultiButton />
{connected && publicKey && (
<span className="text-sm text-gray-500">
{publicKey.toBase58().slice(0, 4)}...
{publicKey.toBase58().slice(-4)}
</span>
)}
</div>
);
}
9.2 Connection Management¶
Connection Configuration¶
// src/lib/connection.ts
import { Connection, Commitment } from "@solana/web3.js";
export const COMMITMENT: Commitment = "confirmed";
export function getConnection(): Connection {
const endpoint = process.env.NEXT_PUBLIC_RPC_URL || "https://api.devnet.solana.com";
return new Connection(endpoint, {
commitment: COMMITMENT,
confirmTransactionInitialTimeout: 60000,
});
}
Using Connection Hook¶
// src/hooks/useConnection.ts
import { useConnection as useWalletConnection } from "@solana/wallet-adapter-react";
import { Connection } from "@solana/web3.js";
export function useConnection(): Connection {
const { connection } = useWalletConnection();
return connection;
}
RPC Endpoints¶
| Network | Endpoint |
|---|---|
| Devnet | https://api.devnet.solana.com |
| Testnet | https://api.testnet.solana.com |
| Mainnet | https://api.mainnet-beta.solana.com |
Production RPC
For production, use a dedicated RPC provider like Helius, QuickNode, or Triton for better reliability and rate limits.
9.3 Anchor Client Integration¶
Generating IDL Types¶
After building your Anchor program, generate TypeScript types:
Creating Program Hook¶
// src/hooks/useAnchorProgram.ts
"use client";
import { useMemo } from "react";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { AnchorProvider, Program, Idl } from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
export function useAnchorProvider() {
const { connection } = useConnection();
const wallet = useWallet();
return useMemo(() => {
if (!wallet.publicKey) return null;
return new AnchorProvider(
connection,
wallet as any,
AnchorProvider.defaultOptions()
);
}, [connection, wallet]);
}
export function useProgram<T extends Idl>(
idl: T,
programId: PublicKey
): Program<T> | null {
const provider = useAnchorProvider();
return useMemo(() => {
if (!provider) return null;
return new Program(idl, programId, provider);
}, [idl, programId, provider]);
}
Token Escrow Hook Example¶
// src/hooks/useTokenEscrow.ts
"use client";
import { useProgram } from "./useAnchorProgram";
import { PublicKey } from "@solana/web3.js";
import { IDL, TokenEscrow } from "@/idl/token_escrow";
const PROGRAM_ID = new PublicKey("EscRToken11111111111111111111111111111111111");
export function useTokenEscrow() {
return useProgram<TokenEscrow>(IDL, PROGRAM_ID);
}
Calling Program Methods¶
// src/features/token-escrow/hooks/useCreateEscrow.ts
"use client";
import { useCallback } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import { PublicKey, SystemProgram } from "@solana/web3.js";
import { TOKEN_PROGRAM_ID } from "@solana/spl-token";
import { BN } from "@coral-xyz/anchor";
import { useTokenEscrow } from "@/hooks/useTokenEscrow";
export function useCreateEscrow() {
const program = useTokenEscrow();
const { publicKey } = useWallet();
const createEscrow = useCallback(
async (params: {
escrowId: number;
taker: PublicKey;
mintA: PublicKey;
mintB: PublicKey;
amountA: number;
amountB: number;
}) => {
if (!program || !publicKey) {
throw new Error("Wallet not connected");
}
const { escrowId, taker, mintA, mintB, amountA, amountB } = params;
// Derive PDAs
const [escrow] = PublicKey.findProgramAddressSync(
[
Buffer.from("escrow"),
publicKey.toBuffer(),
new BN(escrowId).toArrayLike(Buffer, "le", 8),
],
program.programId
);
const [vault] = PublicKey.findProgramAddressSync(
[Buffer.from("vault"), escrow.toBuffer()],
program.programId
);
// Execute transaction
const tx = await program.methods
.initialize(new BN(escrowId), new BN(amountA), new BN(amountB))
.accounts({
escrow,
vault,
maker: publicKey,
taker,
mintA,
mintB,
tokenProgram: TOKEN_PROGRAM_ID,
systemProgram: SystemProgram.programId,
})
.rpc();
return { tx, escrow };
},
[program, publicKey]
);
return { createEscrow };
}
9.4 Transaction Building¶
Building Transactions Manually¶
// src/lib/transactions.ts
import {
Connection,
Transaction,
TransactionInstruction,
PublicKey,
Keypair,
sendAndConfirmTransaction,
} from "@solana/web3.js";
export async function buildAndSendTransaction(
connection: Connection,
payer: Keypair,
instructions: TransactionInstruction[]
): Promise<string> {
const transaction = new Transaction();
// Add instructions
instructions.forEach((ix) => transaction.add(ix));
// Get recent blockhash
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = payer.publicKey;
// Send and confirm
const signature = await sendAndConfirmTransaction(
connection,
transaction,
[payer],
{ commitment: "confirmed" }
);
return signature;
}
Transaction Hook with Wallet Adapter¶
// src/hooks/useTransaction.ts
"use client";
import { useCallback, useState } from "react";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import {
Transaction,
TransactionInstruction,
TransactionSignature,
} from "@solana/web3.js";
interface TransactionState {
loading: boolean;
error: Error | null;
signature: TransactionSignature | null;
}
export function useTransaction() {
const { connection } = useConnection();
const { publicKey, sendTransaction } = useWallet();
const [state, setState] = useState<TransactionState>({
loading: false,
error: null,
signature: null,
});
const execute = useCallback(
async (instructions: TransactionInstruction[]): Promise<string> => {
if (!publicKey) {
throw new Error("Wallet not connected");
}
setState({ loading: true, error: null, signature: null });
try {
const transaction = new Transaction();
instructions.forEach((ix) => transaction.add(ix));
const { blockhash, lastValidBlockHeight } =
await connection.getLatestBlockhash();
transaction.recentBlockhash = blockhash;
transaction.feePayer = publicKey;
const signature = await sendTransaction(transaction, connection);
// Wait for confirmation
await connection.confirmTransaction(
{ signature, blockhash, lastValidBlockHeight },
"confirmed"
);
setState({ loading: false, error: null, signature });
return signature;
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
setState({ loading: false, error: err, signature: null });
throw err;
}
},
[connection, publicKey, sendTransaction]
);
return { ...state, execute };
}
9.5 State Management¶
React Query for Server State¶
// src/providers/QueryProvider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactNode, useState } from "react";
export function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30 seconds
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Fetching Program Accounts¶
// src/hooks/useEscrows.ts
"use client";
import { useQuery } from "@tanstack/react-query";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import { useTokenEscrow } from "./useTokenEscrow";
export function useEscrows() {
const { connection } = useConnection();
const { publicKey } = useWallet();
const program = useTokenEscrow();
return useQuery({
queryKey: ["escrows", publicKey?.toBase58()],
queryFn: async () => {
if (!program || !publicKey) return [];
// Fetch all escrows where user is maker
const makerEscrows = await program.account.escrow.all([
{
memcmp: {
offset: 8, // After discriminator
bytes: publicKey.toBase58(),
},
},
]);
return makerEscrows.map((e) => ({
publicKey: e.publicKey,
...e.account,
}));
},
enabled: !!program && !!publicKey,
refetchInterval: 10000, // Refetch every 10 seconds
});
}
Jotai for Client State¶
// src/store/wallet.ts
import { atom } from "jotai";
import { PublicKey } from "@solana/web3.js";
// Selected network
export const networkAtom = atom<"devnet" | "mainnet-beta">("devnet");
// Transaction history
interface TxRecord {
signature: string;
timestamp: number;
status: "pending" | "confirmed" | "failed";
}
export const transactionHistoryAtom = atom<TxRecord[]>([]);
// Add transaction to history
export const addTransactionAtom = atom(
null,
(get, set, tx: TxRecord) => {
const history = get(transactionHistoryAtom);
set(transactionHistoryAtom, [tx, ...history.slice(0, 49)]);
}
);
9.6 Optimistic Updates¶
Optimistic Mutation Pattern¶
// src/hooks/useCastVote.ts
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useWallet } from "@solana/wallet-adapter-react";
import { PublicKey } from "@solana/web3.js";
import { useDAOGovernance } from "./useDAOGovernance";
import { BN } from "@coral-xyz/anchor";
interface VoteParams {
proposalId: number;
support: boolean;
}
export function useCastVote() {
const queryClient = useQueryClient();
const program = useDAOGovernance();
const { publicKey } = useWallet();
return useMutation({
mutationFn: async ({ proposalId, support }: VoteParams) => {
if (!program || !publicKey) throw new Error("Not connected");
// Build and send transaction
const tx = await program.methods
.castVote(support)
.accounts({
// ... accounts
})
.rpc();
return { tx, proposalId, support };
},
// Optimistic update before server confirms
onMutate: async ({ proposalId, support }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ["proposal", proposalId] });
// Snapshot previous value
const previousProposal = queryClient.getQueryData([
"proposal",
proposalId,
]);
// Optimistically update
queryClient.setQueryData(["proposal", proposalId], (old: any) => {
if (!old) return old;
return {
...old,
votesFor: support
? old.votesFor.add(new BN(1))
: old.votesFor,
votesAgainst: !support
? old.votesAgainst.add(new BN(1))
: old.votesAgainst,
};
});
return { previousProposal };
},
// Rollback on error
onError: (err, variables, context) => {
if (context?.previousProposal) {
queryClient.setQueryData(
["proposal", variables.proposalId],
context.previousProposal
);
}
},
// Refetch after success or error
onSettled: (data, error, variables) => {
queryClient.invalidateQueries({
queryKey: ["proposal", variables.proposalId],
});
},
});
}
9.7 Error Handling¶
Parsing Anchor Errors¶
// src/lib/errors.ts
import { AnchorError } from "@coral-xyz/anchor";
export function parseError(error: unknown): string {
if (error instanceof AnchorError) {
// Parse Anchor program errors
return error.error.errorMessage || error.message;
}
if (error instanceof Error) {
// Check for common Solana errors
if (error.message.includes("insufficient funds")) {
return "Insufficient SOL for transaction fees";
}
if (error.message.includes("Blockhash not found")) {
return "Transaction expired. Please try again.";
}
if (error.message.includes("User rejected")) {
return "Transaction cancelled by user";
}
return error.message;
}
return "An unexpected error occurred";
}
Error Toast Component¶
// src/components/TransactionToast.tsx
"use client";
import { useEffect } from "react";
import { toast } from "sonner"; // or your preferred toast library
interface Props {
signature?: string;
error?: Error | null;
loading?: boolean;
}
export function TransactionToast({ signature, error, loading }: Props) {
useEffect(() => {
if (loading) {
toast.loading("Processing transaction...", { id: "tx" });
} else if (signature) {
toast.success(
<div>
<p>Transaction confirmed!</p>
<a
href={`https://explorer.solana.com/tx/${signature}?cluster=devnet`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
View on Explorer
</a>
</div>,
{ id: "tx" }
);
} else if (error) {
toast.error(parseError(error), { id: "tx" });
}
}, [signature, error, loading]);
return null;
}
9.8 NextJS App Router Structure¶
Project Structure¶
app/
├── src/
│ ├── app/
│ │ ├── layout.tsx # Root layout
│ │ ├── page.tsx # Home page
│ │ ├── token-escrow/
│ │ │ └── page.tsx
│ │ ├── nft-marketplace/
│ │ │ └── page.tsx
│ │ ├── defi-amm/
│ │ │ └── page.tsx
│ │ └── dao-governance/
│ │ └── page.tsx
│ ├── components/
│ │ ├── Layout.tsx
│ │ ├── WalletButton.tsx
│ │ └── TransactionToast.tsx
│ ├── providers/
│ │ ├── SolanaProvider.tsx
│ │ └── QueryProvider.tsx
│ ├── hooks/
│ │ ├── useAnchorProgram.ts
│ │ ├── useTransaction.ts
│ │ └── ...
│ ├── features/
│ │ ├── token-escrow/
│ │ │ ├── components/
│ │ │ └── hooks/
│ │ ├── nft-marketplace/
│ │ ├── defi-amm/
│ │ └── dao-governance/
│ ├── lib/
│ │ ├── connection.ts
│ │ └── errors.ts
│ └── idl/
│ ├── token_escrow.ts
│ ├── nft_marketplace.ts
│ ├── defi_amm.ts
│ └── dao_governance.ts
├── package.json
├── tsconfig.json
├── tailwind.config.js
└── next.config.js
Root Layout¶
// src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { SolanaProvider } from "@/providers/SolanaProvider";
import { QueryProvider } from "@/providers/QueryProvider";
import { Toaster } from "sonner";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Solana DApps",
description: "Production-ready Solana applications",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<QueryProvider>
<SolanaProvider>
{children}
<Toaster position="bottom-right" />
</SolanaProvider>
</QueryProvider>
</body>
</html>
);
}
Feature Page Example¶
// src/app/token-escrow/page.tsx
"use client";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletButton } from "@/components/WalletButton";
import { CreateEscrowForm } from "@/features/token-escrow/components/CreateEscrowForm";
import { EscrowList } from "@/features/token-escrow/components/EscrowList";
export default function TokenEscrowPage() {
const { connected } = useWallet();
return (
<div className="container mx-auto p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Token Escrow</h1>
<WalletButton />
</div>
{connected ? (
<div className="grid gap-8 md:grid-cols-2">
<CreateEscrowForm />
<EscrowList />
</div>
) : (
<div className="text-center py-20">
<p className="text-gray-500 mb-4">
Connect your wallet to manage escrows
</p>
<WalletButton />
</div>
)}
</div>
);
}
9.9 Environment Configuration¶
Environment Variables¶
# .env.local
NEXT_PUBLIC_RPC_URL=https://api.devnet.solana.com
NEXT_PUBLIC_NETWORK=devnet
# Program IDs
NEXT_PUBLIC_TOKEN_ESCROW_PROGRAM_ID=EscRToken11111111111111111111111111111111111
NEXT_PUBLIC_NFT_MARKETPLACE_PROGRAM_ID=NftMkt11111111111111111111111111111111111111
NEXT_PUBLIC_DEFI_AMM_PROGRAM_ID=AMM1111111111111111111111111111111111111111
NEXT_PUBLIC_DAO_GOVERNANCE_PROGRAM_ID=Gov11111111111111111111111111111111111111111
Next.js Config¶
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
net: false,
tls: false,
};
return config;
},
};
module.exports = nextConfig;
9.10 Vercel Deployment¶
Vercel Configuration¶
// vercel.json
{
"buildCommand": "pnpm build",
"outputDirectory": ".next",
"framework": "nextjs",
"regions": ["iad1"]
}
Deployment Steps¶
-
Push to GitHub:
-
Connect to Vercel:
- Go to vercel.com
- Import your GitHub repository
-
Configure environment variables
-
Environment Variables in Vercel:
- Add
NEXT_PUBLIC_RPC_URL - Add
NEXT_PUBLIC_NETWORK -
Add program IDs
-
Deploy:
- Vercel automatically deploys on push to main
9.11 Interactive Exercise¶
Try It: Build a DApp Frontend
Open StackBlitz and implement:
- Set up wallet adapter provider
- Create a wallet connect button
- Fetch account data from devnet
- Build and send a simple transaction
Starter Template¶
// Simple wallet connection example
"use client";
import { useWallet } from "@solana/wallet-adapter-react";
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
import { useConnection } from "@solana/wallet-adapter-react";
import { LAMPORTS_PER_SOL } from "@solana/web3.js";
import { useEffect, useState } from "react";
export default function Home() {
const { publicKey, connected } = useWallet();
const { connection } = useConnection();
const [balance, setBalance] = useState<number | null>(null);
useEffect(() => {
if (publicKey) {
connection.getBalance(publicKey).then((bal) => {
setBalance(bal / LAMPORTS_PER_SOL);
});
}
}, [publicKey, connection]);
return (
<main className="min-h-screen p-8">
<div className="max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-8">Solana DApp</h1>
<WalletMultiButton />
{connected && publicKey && (
<div className="mt-8 p-4 bg-gray-100 rounded">
<p className="font-mono text-sm">
{publicKey.toBase58()}
</p>
<p className="mt-2">
Balance: {balance?.toFixed(4)} SOL
</p>
</div>
)}
</div>
</main>
);
}
Summary¶
In this module, you learned:
- Wallet Adapter: Setting up wallet connection with multiple wallet support
- Connection Management: Configuring RPC endpoints and commitment levels
- Anchor Integration: Generating clients and calling program methods
- Transaction Building: Creating and sending transactions with proper confirmation
- State Management: React Query for server state, Jotai for client state
- Optimistic Updates: Improving UX with immediate UI feedback
- Error Handling: Parsing Anchor errors and displaying user-friendly messages
- NextJS Deployment: Deploying to Vercel with environment configuration
Next Steps¶
Continue to Module 10: Backend Services to learn:
- FastAPI backend architecture
- Poem (Rust) high-performance services
- Indexing blockchain data
- Transaction relay services