Skip to content

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:

# Build generates IDL in target/idl/
anchor build

# Types are generated in target/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

  1. Push to GitHub:

    git push origin main
    

  2. Connect to Vercel:

  3. Go to vercel.com
  4. Import your GitHub repository
  5. Configure environment variables

  6. Environment Variables in Vercel:

  7. Add NEXT_PUBLIC_RPC_URL
  8. Add NEXT_PUBLIC_NETWORK
  9. Add program IDs

  10. Deploy:

  11. Vercel automatically deploys on push to main

9.11 Interactive Exercise

Try It: Build a DApp Frontend

Open StackBlitz and implement:

  1. Set up wallet adapter provider
  2. Create a wallet connect button
  3. Fetch account data from devnet
  4. 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

Back to Course Home