Frontend Development
Developing the frontend of a dApp on Sei EVM involves connecting to wallets, interacting with the blockchain via RPC endpoints, and signing and broadcasting transactions. This tutorial will demonstrate how to build a simple ERC20 token interface using three popular libraries:
- Ethers.js - A complete and compact library for interacting with EVM blockchains. Known for its simplicity and extensive functionality.
- Viem - A lightweight and modular TypeScript interface for Ethereum
- Wagmi - A React hooks library built on top of Viem that simplifies wallet connection and interaction. Provides hooks for interacting with Ethereum wallets and contracts for use with modern frontend libraries and frameworks.
We’ll implement the same functionality using each library so you can compare their approaches and choose the one that best fits your development style.
Deploy to Testnet First
It is highly recommended that you deploy to testnet (atlantic-2) first and verify everything works as expected before committing to mainnet. Doing so helps you catch bugs early, avoid unnecessary gas costs, and keep your users safe.
When to Use Each Library
Ethers.js
Ethers.js is ideal for developers who want a comprehensive, battle-tested library with straightforward API design. It’s great for both simple and complex dApps, especially when you’re not using React or need custom state management.
- Pros:
- Comprehensive and easy-to-use API
- Well-documented with a large community
- Works well with both TypeScript and JavaScript
- All-in-one solution for wallet connection and contract interaction
 
- Cons:
- Larger bundle size compared to Viem
- Not specifically designed for React hooks integration
 
Viem
Viem is perfect for developers who want fine-grained control over their blockchain interactions and appreciate a modular, lightweight approach. It’s a good choice when bundle size matters and when you have specific requirements for how contract interactions should work.
- Pros:
- Lightweight and modular
- Excellent TypeScript support with better type safety
- Lower-level API gives more control
- Smaller bundle size
 
- Cons:
- Steeper learning curve
- Requires more boilerplate code for some operations
- Requires separate handling for wallet connection and contract interactions
 
Wagmi
Wagmi is the best choice for React developers building dApps who want to leverage React’s state management capabilities. It abstracts away much of the complexity of blockchain interactions via hooks, making it easy to build reactive UIs that respond to chain state.
- Pros:
- React-specific hooks for seamless integration
- Handles complex state management for you
- Built on top of Viem, combining its benefits
- Convenient caching and auto-refreshing for contract data
 
- Cons:
- Only works with React
- Adds another dependency layer
- Opinionated about how data should be managed in your app
 
Requirements
Before starting, ensure you have:
- Node.js & NPM installed
- One of the Sei wallets listed here
Creating a React Project
Start by creating a new React project using Vite’s TypeScript template for streamlined development:
npm create vite@latest sei-token-interface -- --template react-tsThis command creates a new folder with a React project using TypeScript. Open sei-token-interface in your favorite IDE.
Project Structure
For clarity, we’ll create separate components for each library implementation. First, let’s set up the project structure:
cd sei-token-interface
mkdir src/components
touch src/components/EthersInterface.tsx
touch src/components/ViemInterface.tsx
touch src/components/WagmiInterface.tsx
mkdir src/shared
touch src/shared/constants.ts
touch src/wagmi.tsDefining the ERC20 Contract Details
TOKEN_CONTRACT_ADDRESS in the constants file with your actual deployed contract address, and update the RPC URL in the Sei chain configuration. You can find a list of existing ERC20 contracts on Sei Mainnet here: Sei Assets Let’s create a shared constants file for our project:
// Constants used across different implementations
export const ERC20_ABI = [
  {
    inputs: [],
    name: 'name',
    outputs: [
      {
        internalType: 'string',
        name: '',
        type: 'string'
      }
    ],
    stateMutability: 'view',
    type: 'function'
  },
  {
    inputs: [],
    name: 'symbol',
    outputs: [
      {
        internalType: 'string',
        name: '',
        type: 'string'
      }
    ],
    stateMutability: 'view',
    type: 'function'
  },
  {
    inputs: [],
    name: 'decimals',
    outputs: [
      {
        internalType: 'uint8',
        name: '',
        type: 'uint8'
      }
    ],
    stateMutability: 'view',
    type: 'function'
  },
  {
    inputs: [
      {
        internalType: 'address',
        name: 'account',
        type: 'address'
      }
    ],
    name: 'balanceOf',
    outputs: [
      {
        internalType: 'uint256',
        name: '',
        type: 'uint256'
      }
    ],
    stateMutability: 'view',
    type: 'function'
  },
  {
    inputs: [
      {
        internalType: 'address',
        name: 'to',
        type: 'address'
      },
      {
        internalType: 'uint256',
        name: 'amount',
        type: 'uint256'
      }
    ],
    name: 'transfer',
    outputs: [
      {
        internalType: 'bool',
        name: '',
        type: 'bool'
      }
    ],
    stateMutability: 'nonpayable',
    type: 'function'
  },
  {
    anonymous: false,
    inputs: [
      {
        indexed: true,
        internalType: 'address',
        name: 'from',
        type: 'address'
      },
      {
        indexed: true,
        internalType: 'address',
        name: 'to',
        type: 'address'
      },
      {
        indexed: false,
        internalType: 'uint256',
        name: 'value',
        type: 'uint256'
      }
    ],
    name: 'Transfer',
    type: 'event'
  }
];
 
// TODO: Replace with your deployed ERC20 token contract address
export const TOKEN_CONTRACT_ADDRESS = '0xYourTokenContractAddress';Option 1: Ethers.js Implementation
Install ethers.js first:
npm install ethersLet’s start with the Ethers.js implementation:
- Checks for any EVM compatible wallet extension.
- Establishes a connection to Sei Mainnet via the connected wallet, using ethers.js BrowserProvider.
- Creates an ethers.js contract instance with the signer from the wallet, setting it in the contract state for later use.
import { useState, useEffect } from 'react';
import { BrowserProvider, Contract, formatEther, parseEther } from 'ethers';
import { ERC20_ABI, TOKEN_CONTRACT_ADDRESS } from '../shared/constants';
 
export function EthersInterface() {
  const [balance, setBalance] = useState<string>();
  const [contract, setContract] = useState<Contract>();
  const [recipientAddress, setRecipientAddress] = useState('');
  const [amount, setAmount] = useState('');
  const [isTransferring, setIsTransferring] = useState(false);
  const [tokenInfo, setTokenInfo] = useState<{ name: string; symbol: string }>();
  const [address, setAddress] = useState<string>();
 
  // Sei EVM network configuration
  const SEI_NETWORK_PARAMS = {
    chainId: '0x531', // 1329 in hexadecimal
    chainName: 'Sei Network',
    nativeCurrency: {
      name: 'Sei',
      symbol: 'SEI',
      decimals: 18
    },
    rpcUrls: ['https://evm-rpc.sei-apis.com'],
    blockExplorerUrls: ['https://seitrace.com']
  };
 
  const fetchBalance = async () => {
    if (!contract || !address) return;
    try {
      const balance = await contract.balanceOf(address);
      setBalance(formatEther(balance));
    } catch (error) {
      console.error('Failed to fetch balance:', error);
    }
  };
 
  const fetchTokenInfo = async () => {
    if (!contract) return;
    try {
      const name = await contract.name();
      const symbol = await contract.symbol();
      setTokenInfo({ name, symbol });
    } catch (error) {
      console.error('Failed to fetch token info:', error);
    }
  };
 
  useEffect(() => {
    if (contract) {
      fetchTokenInfo();
      fetchBalance();
    }
  }, [contract, address]);
 
  const connectWallet = async () => {
    if (window.ethereum) {
      try {
        const provider = new BrowserProvider(window.ethereum);
        // Attempt to switch to the Sei network
        try {
          await provider.send('wallet_switchEthereumChain', [{ chainId: SEI_NETWORK_PARAMS.chainId }]);
        } catch (switchError: any) {
          // Error code 4902 indicates the chain is not added in MetaMask
          if (switchError.code === 4902) {
            await provider.send('wallet_addEthereumChain', [SEI_NETWORK_PARAMS]);
          } else {
            throw switchError;
          }
        }
        // Request account access
        await provider.send('eth_requestAccounts', []);
        const signer = await provider.getSigner();
        const userAddress = await signer.getAddress();
        setAddress(userAddress);
        const tokenContract = new Contract(TOKEN_CONTRACT_ADDRESS, ERC20_ABI, signer);
        setContract(tokenContract);
      } catch (error) {
        console.error('Failed to connect wallet:', error);
        alert('Failed to connect wallet. See console for details.');
      }
    } else {
      alert('No EVM compatible wallet installed');
    }
  };
 
  const transferTokens = async () => {
    if (!contract || !recipientAddress || !amount) return;
    try {
      setIsTransferring(true);
      const tx = await contract.transfer(recipientAddress, parseEther(amount));
      console.log('Transaction sent:', tx.hash);
      const receipt = await tx.wait();
      console.log('Transaction confirmed:', receipt);
      await fetchBalance();
      setRecipientAddress('');
      setAmount('');
    } catch (error) {
      console.error('Transfer failed:', error);
      alert('Transfer failed. Check console for details.');
    } finally {
      setIsTransferring(false);
    }
  };
 
  return (
    <div className="card">
      <h2>Ethers.js v6 Implementation</h2>
      {contract ? (
        <div>
          <h3>
            {tokenInfo?.name} ({tokenInfo?.symbol})
          </h3>
          <p>
            Connected Address: {address?.slice(0, 6)}...{address?.slice(-4)}
          </p>
          <p>
            Balance: {balance} {tokenInfo?.symbol}
          </p>
          <div style={{ marginTop: '20px' }}>
            <input type="text" placeholder="Recipient Address" value={recipientAddress} onChange={(e) => setRecipientAddress(e.target.value)} style={{ marginBottom: '10px', width: '300px' }} />
            <br />
            <input type="number" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} style={{ marginBottom: '10px', width: '300px' }} />
            <br />
            <button disabled={isTransferring} onClick={transferTokens}>
              {isTransferring ? 'Transferring...' : 'Transfer Tokens'}
            </button>
          </div>
        </div>
      ) : (
        <button onClick={connectWallet}>Connect with Ethers.js v6</button>
      )}
    </div>
  );
}Option 2: Viem Implementation
Install Viem first:
npm install viemNow implement the Viem interface:
import { useState, useEffect } from 'react';
import { createWalletClient, custom, parseEther, formatEther } from 'viem';
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
import { ERC20_ABI, TOKEN_CONTRACT_ADDRESS } from '../shared/constants';
 
// Sei Mainnet settings
const seiMainnet = {
  ...mainnet, // using mainnet as a base
  id: 1329,
  name: 'Sei Mainnet',
  rpcUrls: {
    default: { http: ['https://evm-rpc.sei-apis.com'] }
  }
};
 
export function ViemInterface() {
  const [balance, setBalance] = useState<string>();
  const [address, setAddress] = useState<string>();
  const [walletClient, setWalletClient] = useState<any>(null);
  const [publicClient, setPublicClient] = useState<any>(null);
  const [recipientAddress, setRecipientAddress] = useState('');
  const [amount, setAmount] = useState('');
  const [isTransferring, setIsTransferring] = useState(false);
  const [tokenInfo, setTokenInfo] = useState<{ name: string; symbol: string }>();
 
  useEffect(() => {
    // Initialize the public client
    const newPublicClient = createPublicClient({
      chain: seiMainnet,
      transport: http()
    });
    setPublicClient(newPublicClient);
  }, []);
 
  useEffect(() => {
    if (walletClient && publicClient && address) {
      fetchTokenInfo();
      fetchBalance();
    }
  }, [walletClient, publicClient, address]);
 
  const fetchBalance = async () => {
    if (!publicClient || !address) return;
 
    try {
      const balance = await publicClient.readContract({
        address: TOKEN_CONTRACT_ADDRESS as `0x${string}`,
        abi: ERC20_ABI,
        functionName: 'balanceOf',
        args: [address]
      });
 
      setBalance(formatEther(balance as bigint));
    } catch (error) {
      console.error('Error fetching balance:', error);
    }
  };
 
  const fetchTokenInfo = async () => {
    if (!publicClient) return;
 
    try {
      const [name, symbol] = await Promise.all([
        publicClient.readContract({
          address: TOKEN_CONTRACT_ADDRESS,
          abi: ERC20_ABI,
          functionName: 'name'
        }),
        publicClient.readContract({
          address: TOKEN_CONTRACT_ADDRESS,
          abi: ERC20_ABI,
          functionName: 'symbol'
        })
      ]);
 
      setTokenInfo({ name: name as string, symbol: symbol as string });
    } catch (error) {
      console.error('Error fetching token info:', error);
    }
  };
  const connectWallet = async () => {
    if (!window.ethereum) {
      alert('No EVM compatible wallet installed');
      return;
    }
 
    try {
      const [userAddress] = await window.ethereum.request({
        method: 'eth_requestAccounts'
      });
 
      setAddress(userAddress);
 
      const newWalletClient = createWalletClient({
        chain: seiMainnet,
        transport: custom(window.ethereum)
      });
 
      setWalletClient(newWalletClient);
    } catch (error) {
      console.error('Failed to connect wallet:', error);
      alert('Failed to connect wallet. See console for details.');
    }
  };
 
  const transferTokens = async () => {
    if (!walletClient || !address || !recipientAddress || !amount) return;
 
    try {
      setIsTransferring(true);
 
      // Prepare the contract call parameters
      const abi = ERC20_ABI;
      const functionName = 'transfer';
      const args = [recipientAddress, parseEther(amount)];
 
      // Execute the transaction
      const hash = await walletClient.writeContract({
        address: TOKEN_CONTRACT_ADDRESS as `0x${string}`,
        abi,
        functionName,
        args
      });
 
      console.log('Transaction sent:', hash);
 
      // Wait for the transaction to be mined
      const receipt = await publicClient.waitForTransactionReceipt({ hash });
      console.log('Transaction confirmed:', receipt);
 
      // Refresh the balance
      await fetchBalance();
 
      // Reset form
      setRecipientAddress('');
      setAmount('');
    } catch (error) {
      console.error('Transfer failed:', error);
      alert('Transfer failed. Check console for details.');
    } finally {
      setIsTransferring(false);
    }
  };
 
  return (
    <div className="card">
      <h2>Viem Implementation</h2>
      {address ? (
        <div>
          <h3>
            {tokenInfo?.name} ({tokenInfo?.symbol})
          </h3>
          <p>
            Connected Address: {address.slice(0, 6)}...{address.slice(-4)}
          </p>
          <p>
            Balance: {balance} {tokenInfo?.symbol}
          </p>
          <div style={{ marginTop: '20px' }}>
            <input type="text" placeholder="Recipient Address" value={recipientAddress} onChange={(e) => setRecipientAddress(e.target.value)} style={{ marginBottom: '10px', width: '300px' }} />
            <br />
            <input type="number" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} style={{ marginBottom: '10px', width: '300px' }} />
            <br />
            <button disabled={isTransferring} onClick={transferTokens}>
              {isTransferring ? 'Transferring...' : 'Transfer Tokens'}
            </button>
          </div>
        </div>
      ) : (
        <button onClick={connectWallet}>Connect with Viem</button>
      )}
    </div>
  );
}Option 3: Wagmi Implementation
Install Wagmi and configure it:
npm install wagmi viem @tanstack/react-queryFirst, let’s create a Wagmi configuration file:
import { http, createConfig } from 'wagmi';
import { mainnet } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';
 
// Sei Mainnet settings
const seiMainnet = {
  ...mainnet, // using mainnet as a base
  id: 1329,
  name: 'Sei Mainnet',
  rpcUrls: {
    default: { http: ['https://evm-rpc.sei-apis.com'] }
  }
};
 
// Sei Testnet settings
const seiTestnet = {
  ...mainnet, // using mainnet as a base, then override
  id: 1328,
  name: 'Sei Testnet',
  rpcUrls: {
    default: { http: ['https://evm-rpc-testnet.sei-apis.com'] }
  }
};
 
export const config = createConfig({
  chains: [seiMainnet, seiTestnet],
  connectors: [injected()],
  transports: {
    [seiMainnet.id]: http(),
    [seiTestnet.id]: http()
  }
});Now implement the Wagmi interface:
import { useState } from 'react';
import { useAccount, useConnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { injected } from 'wagmi/connectors';
import { parseEther, formatEther } from 'viem';
import { ERC20_ABI, TOKEN_CONTRACT_ADDRESS } from '../shared/constants';
 
export function WagmiInterface() {
  const [recipientAddress, setRecipientAddress] = useState('');
  const [amount, setAmount] = useState('');
 
  // Wagmi hooks
  const { address, isConnected } = useAccount();
  const { connect } = useConnect();
 
  // For debugging
  console.log('Connection status:', { address, isConnected });
 
  // Read from contract
  const { data: name } = useReadContract({
    address: TOKEN_CONTRACT_ADDRESS as `0x${string}`,
    abi: ERC20_ABI,
    functionName: 'name'
  });
 
  const { data: symbol } = useReadContract({
    address: TOKEN_CONTRACT_ADDRESS as `0x${string}`,
    abi: ERC20_ABI,
    functionName: 'symbol'
  });
 
  const { data: balance, refetch: refetchBalance } = useReadContract({
    address: TOKEN_CONTRACT_ADDRESS as `0x${string}`,
    abi: ERC20_ABI,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
    query: {
      enabled: !!address
    }
  });
 
  // Write to contract
  const { writeContract, data: hash, isPending: isTransferring, error } = useWriteContract();
 
  // Wait for transaction
  const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
    hash
  });
 
  // Connect wallet
  const connectWallet = async () => {
    try {
      connect({ connector: injected() });
    } catch (err) {
      console.error('Failed to connect:', err);
    }
  };
 
  // Transfer tokens
  const transferTokens = async () => {
    if (!recipientAddress || !amount) return;
    try {
      writeContract({
        address: TOKEN_CONTRACT_ADDRESS as `0x${string}`,
        abi: ERC20_ABI,
        functionName: 'transfer',
        args: [recipientAddress, parseEther(amount)]
      });
    } catch (err) {
      console.error('Transfer failed:', err);
    }
  };
 
  // Handle successful transfer
  if (isConfirmed) {
    refetchBalance();
    setRecipientAddress('');
    setAmount('');
  }
 
  return (
    <div className="card">
      <h2>Wagmi Implementation</h2>
      {isConnected ? (
        <div>
          <h3>
            {(name as string) || 'Loading...'} ({(symbol as string) || '...'})
          </h3>
          <p>
            Connected Address: {address?.slice(0, 6)}...{address?.slice(-4)}
          </p>
          <p>
            Balance: {balance ? formatEther(balance as bigint) : '0'} {(symbol as string) || ''}
          </p>
          <div style={{ marginTop: '20px' }}>
            <input type="text" placeholder="Recipient Address" value={recipientAddress} onChange={(e) => setRecipientAddress(e.target.value)} style={{ marginBottom: '10px', width: '300px' }} />
            <br />
            <input type="number" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} style={{ marginBottom: '10px', width: '300px' }} />
            <br />
            <button disabled={isTransferring || isConfirming} onClick={transferTokens}>
              {isTransferring ? 'Preparing Transaction...' : isConfirming ? 'Confirming Transaction...' : 'Transfer Tokens'}
            </button>
            {error && <p style={{ color: 'red' }}>Error: {(error as Error).message}</p>}
          </div>
        </div>
      ) : (
        <button onClick={connectWallet}>Connect with Wagmi</button>
      )}
    </div>
  );
}Updating the Main App to Display all Implementations
Now update your App.tsx to include all three interface options:
import { useState } from 'react';
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { EthersInterface } from './components/EthersInterface';
import { ViemInterface } from './components/ViemInterface';
import { WagmiInterface } from './components/WagmiInterface';
import { config } from './wagmi';
 
function App() {
  const [selectedLib, setSelectedLib] = useState<string | null>(null);
 
  const queryClient = new QueryClient();
 
  return (
    <div className="app-container">
      <h1>Sei ERC20 Token Interface</h1>
      <p>Choose a library implementation:</p>
 
      <div className="button-group">
        <button onClick={() => setSelectedLib('ethers')} className={selectedLib === 'ethers' ? 'active' : ''}>
          Ethers.js
        </button>
        <button onClick={() => setSelectedLib('viem')} className={selectedLib === 'viem' ? 'active' : ''}>
          Viem
        </button>
        <button onClick={() => setSelectedLib('wagmi')} className={selectedLib === 'wagmi' ? 'active' : ''}>
          Wagmi (React Hooks)
        </button>
      </div>
 
      <div className="interface-container">
        {selectedLib === 'ethers' && <EthersInterface />}
        {selectedLib === 'viem' && <ViemInterface />}
        {selectedLib === 'wagmi' && (
          <WagmiProvider config={config}>
            <QueryClientProvider client={queryClient}>
              <WagmiInterface />
            </QueryClientProvider>
          </WagmiProvider>
        )}
        {!selectedLib && (
          <div className="placeholder">
            <p>Select a library to see its implementation</p>
          </div>
        )}
      </div>
    </div>
  );
}
 
export default App;Polyfills for Browser Environment
When developing frontend applications for the blockchain, you might need polyfills for Node.js-specific features like Buffer. Add these polyfills to your project:
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { Buffer } from 'buffer';
 
// Polyfill self for browser and global for Node.js
const globalObject = typeof self !== 'undefined' ? self : global;
 
Object.assign(globalObject, {
  Buffer: Buffer
});
 
ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);Running the Application
To see your app in action, run:
npm run devThis will start a local development server, at http://localhost:5173. You can toggle between the different library implementations to see how each one works.
To build your application, run:
npm run buildThen deploy via:
npm run previewThis will start a local production server, at http://localhost:4173. You can toggle between the different library implementations to see how each one works.