Ethers.js has become the go-to library for Web3 development in 2025. Its lightweight design, excellent TypeScript support, and modern API make it perfect for building production-ready decentralized applications (DApps). Let's dive deep into practical DApp development using Ethers.js v6.
Why Ethers.js in 2025?
Ethers.js v6 brings several improvements over v5:
- Native TypeScript Support: Full type safety out of the box
- Compact Bundle Size: ~200KB minified vs Web3.js ~600KB
- Better Async Handling: Promises throughout, no callback hell
- Improved Error Handling: Detailed error codes and messages
- Modern API Design: Cleaner, more intuitive methods
Project Setup
First, let's set up a modern DApp project:
bashnpm create vite@latest my-dapp -- --template react-ts cd my-dapp npm install ethers@6 npm install -D @types/node
Wallet Connection Pattern
The most critical aspect of any DApp is wallet connection. Here's a production-ready implementation:
typescript// src/hooks/useWallet.ts import { useState, useEffect } from 'ethers'; import { BrowserProvider } from 'ethers'; interface WalletState { address: string | null; chainId: number | null; balance: string; isConnected: boolean; } export function useWallet() { const [wallet, setWallet] = useState<WalletState>({ address: null, chainId: null, balance: '0', isConnected: false, }); const [provider, setProvider] = useState<BrowserProvider | null>(null); // Connect to wallet const connect = async () => { if (!window.ethereum) { throw new Error('MetaMask not installed'); } try { const ethersProvider = new BrowserProvider(window.ethereum); const signer = await ethersProvider.getSigner(); const address = await signer.getAddress(); const balance = await ethersProvider.getBalance(address); const network = await ethersProvider.getNetwork(); setWallet({ address, chainId: Number(network.chainId), balance: formatEther(balance), isConnected: true, }); setProvider(ethersProvider); } catch (error) { console.error('Connection error:', error); throw error; } }; // Disconnect const disconnect = () => { setWallet({ address: null, chainId: null, balance: '0', isConnected: false, }); setProvider(null); }; // Listen for account/network changes useEffect(() => { if (window.ethereum) { const handleAccountsChanged = (accounts: string[]) => { if (accounts.length === 0) { disconnect(); } else { connect(); } }; const handleChainChanged = () => { window.location.reload(); }; window.ethereum.on('accountsChanged', handleAccountsChanged); window.ethereum.on('chainChanged', handleChainChanged); return () => { window.ethereum.removeListener('accountsChanged', handleAccountsChanged); window.ethereum.removeListener('chainChanged', handleChainChanged); }; } }, []); return { wallet, connect, disconnect, provider }; }
Smart Contract Interaction
Let's create a robust contract service:
typescript// src/services/contractService.ts import { ethers, Contract, BrowserProvider } from 'ethers'; // ERC-20 ABI (minimal) const ERC20_ABI = [ 'function balanceOf(address owner) view returns (uint256)', 'function transfer(address to, uint256 amount) returns (bool)', 'function approve(address spender, uint256 amount) returns (bool)', 'function allowance(address owner, address spender) view returns (uint256)', 'event Transfer(address indexed from, address indexed to, uint256 value)', ]; export class ContractService { private contract: Contract; private signer: ethers.JsonRpcSigner; constructor( address: string, abi: any[], provider: BrowserProvider, signer: ethers.JsonRpcSigner ) { this.contract = new Contract(address, abi, provider); this.signer = signer; } // Read from contract async balanceOf(address: string): Promise<bigint> { try { const balance = await this.contract.balanceOf(address); return balance; } catch (error) { console.error('Balance error:', error); throw error; } } // Write to contract with proper error handling async transfer(to: string, amount: bigint): Promise<ethers.ContractTransactionReceipt> { try { // Estimate gas first const estimatedGas = await this.contract .connect(this.signer) .transfer.estimateGas(to, amount); // Add 20% buffer to gas estimate const gasLimit = estimatedGas * 120n / 100n; // Execute transaction const tx = await this.contract .connect(this.signer) .transfer(to, amount, { gasLimit }); // Wait for confirmation const receipt = await tx.wait(); return receipt as ethers.ContractTransactionReceipt; } catch (error) { console.error('Transfer error:', error); throw error; } } // Listen to events listenToTransfers(callback: (from: string, to: string, value: bigint) => void) { this.contract.on('Transfer', (from, to, value) => { callback(from, to, value); }); // Return cleanup function return () => { this.contract.removeAllListeners(); }; } }
Transaction Queue System
Production DApps need robust transaction handling:
typescript// src/services/transactionQueue.ts import { ethers, ContractTransactionReceipt } from 'ethers'; interface QueuedTransaction { id: string; contract: string; method: string; params: any[]; status: 'pending' | 'confirming' | 'confirmed' | 'failed'; txHash?: string; error?: string; } export class TransactionQueue { private queue: Map<string, QueuedTransaction> = new Map(); private processing = false; async add( contract: any, method: string, params: any[], signer: ethers.JsonRpcSigner ): Promise<string> { const id = `${Date.now()}-${Math.random()}`; this.queue.set(id, { id, contract: contract.target, method, params, status: 'pending', }); this.processQueue(contract, signer); return id; } private async processQueue(contract: any, signer: ethers.JsonRpcSigner) { if (this.processing) return; this.processing = true; for (const [id, tx] of this.queue) { if (tx.status !== 'pending') continue; try { // Execute transaction const contractTx = await contract.connect(signer)[tx.method](...tx.params); this.queue.set(id, { ...tx, status: 'confirming', txHash: contractTx.hash, }); // Wait for confirmation const receipt = await contractTx.wait(); this.queue.set(id, { ...tx, status: 'confirmed', }); } catch (error: any) { this.queue.set(id, { ...tx, status: 'failed', error: error.message, }); } } this.processing = false; } getStatus(id: string): QueuedTransaction | undefined { return this.queue.get(id); } get all(): QueuedTransaction[] { return Array.from(this.queue.values()); } }
Gas Optimization Strategies
Gas costs are crucial in 2025. Here's how to optimize:
typescript// src/utils/gasOptimizer.ts import { ethers } from 'ethers'; export class GasOptimizer { // Get current gas price with strategic timing static async getOptimizedGasPrice(provider: ethers.Provider): Promise<bigint> { const feeData = await provider.getFeeData(); // Use EIP-1559 if available if (feeData.maxFeePerGas && feeData.maxPriorityFeePerGas) { // Set maxPriorityFee to 2 gwei const maxPriorityFee = ethers.parseUnits('2', 'gwei'); // Calculate maxFee based on current base fee const baseFee = feeData.maxFeePerGas - feeData.maxPriorityFeePerGas!; const maxFee = baseFee + maxPriorityFee; return maxFee; } // Fallback to legacy gas price return feeData.gasPrice || ethers.parseUnits('20', 'gwei'); } // Estimate gas with buffer static async estimateGasWithBuffer( contract: any, method: string, params: any[], bufferPercent: number = 20 ): Promise<bigint> { const estimated = await contract[method].estimateGas(...params); const buffer = estimated * BigInt(bufferPercent) / 100n; return estimated + buffer; } // Batch transactions to save gas static async batchTransactions( transactions: Array<{ contract: any; method: string; params: any[] }>, signer: ethers.JsonRpcSigner ): Promise<ethers.ContractTransactionReceipt[]> { const receipts: ethers.ContractTransactionReceipt[] = []; for (const tx of transactions) { const receipt = await tx.contract.connect(signer)[tx.method](...tx.params); const result = await receipt.wait(); receipts.push(result as ethers.ContractTransactionReceipt); } return receipts; } }
Error Handling & User Experience
Proper error handling is essential:
typescript// src/utils/errorHandler.ts import { ethers } from 'ethers'; export class DAppError extends Error { constructor( message: string, public code?: string, public txHash?: string ) { super(message); this.name = 'DAppError'; } } export function handleTransactionError(error: any): DAppError { // User rejected transaction if (error.code === 4001) { return new DAppError('Transaction was rejected by user', 'USER_REJECTED'); } // Insufficient funds if (error.code === -32000 || error.message?.includes('insufficient funds')) { return new DAppError('Insufficient funds for transaction', 'INSUFFICIENT_FUNDS'); } // Gas estimation failed if (error.message?.includes('gas estimation failed')) { return new DAppError( 'Transaction would fail. Please check your balance and allowances.', 'GAS_ESTIMATION_FAILED' ); } // Slippage too low if (error.message?.includes('slippage')) { return new DAppError( 'Transaction failed due to price movement. Try again with higher slippage.', 'SLIPPAGE_ERROR' ); } // Contract execution reverted if (error.message?.includes('execution reverted')) { return new DAppError( 'Transaction failed. The contract execution reverted.', 'CONTRACT_REVERTED' ); } // Network error if (error.code === 'NETWORK_ERROR') { return new DAppError( 'Network error. Please check your connection.', 'NETWORK_ERROR' ); } // Unknown error return new DAppError( error.message || 'An unknown error occurred', 'UNKNOWN_ERROR' ); }
React Integration Example
Here's a complete React component using our patterns:
typescript// src/components/TokenTransfer.tsx import React, { useState } from 'react'; import { useWallet } from '../hooks/useWallet'; import { ContractService } from '../services/contractService'; import { handleTransactionError } from '../utils/errorHandler'; import { parseEther } from 'ethers'; const TOKEN_ADDRESS = '0x...'; // Your token address export function TokenTransfer() { const { wallet, provider, connect } = useWallet(); const [recipient, setRecipient] = useState(''); const [amount, setAmount] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const [success, setSuccess] = useState(false); const handleTransfer = async () => { if (!provider || !wallet.address) return; setLoading(true); setError(null); setSuccess(false); try { const signer = await provider.getSigner(); const contractService = new ContractService( TOKEN_ADDRESS, ERC20_ABI, provider, signer ); const amountWei = parseEther(amount); const receipt = await contractService.transfer(recipient, amountWei); setSuccess(true); console.log('Transaction confirmed:', receipt.hash); } catch (error: any) { const dAppError = handleTransactionError(error); setError(dAppError.message); } finally { setLoading(false); } }; if (!wallet.isConnected) { return <button onClick={connect}>Connect Wallet</button>; } return ( <div className="token-transfer"> <h3>Transfer Tokens</h3> <p>Balance: {wallet.balance}</p> <input type="text" placeholder="Recipient address" value={recipient} onChange={(e) => setRecipient(e.target.value)} /> <input type="number" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} /> {error && <div className="error">{error}</div>} {success && <div className="success">Transfer successful!</div>} <button onClick={handleTransfer} disabled={loading || !recipient || !amount} > {loading ? 'Transferring...' : 'Transfer'} </button> </div> ); }
Production Best Practices
1. Network Switching
typescript// Switch to specific network async function switchNetwork(chainId: number) { if (!window.ethereum) return; try { await window.ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: `0x${chainId.toString(16)}` }], }); } catch (error: any) { // Network doesn't exist, add it if (error.code === 4902) { await window.ethereum.request({ method: 'wallet_addEthereumChain', params: [{ chainId: `0x${chainId.toString(16)}`, rpcUrl: 'https://mainnet.infura.io/v3/YOUR-PROJECT-ID', }], }); } } }
2. Unit Conversion Utilities
typescript// src/utils/units.ts import { ethers } from 'ethers'; export function formatUnits(value: bigint, decimals: number = 18): string { return ethers.formatUnits(value, decimals); } export function parseUnits(value: string, decimals: number = 18): bigint { return ethers.parseUnits(value, decimals); } export function formatEther(value: bigint): string { return ethers.formatEther(value); } export function parseEther(value: string): bigint { return ethers.parseEther(value); }
3. Local Storage for Persistence
typescript// Persist wallet connection const saveConnection = (address: string) => { localStorage.setItem('wallet-connected', address); }; const loadConnection = (): string | null => { return localStorage.getItem('wallet-connected'); }; const clearConnection = () => { localStorage.removeItem('wallet-connected'); };
Real-World DApp Example: DeFi Dashboard
Here's a production-ready DeFi dashboard component:
typescript// src/components/DeFiDashboard.tsx import React, { useEffect, useState } from 'react'; import { useWallet } from '../hooks/useWallet'; import { ContractService } from '../services/contractService'; interface TokenBalance { symbol: string; balance: string; decimals: number; } export function DeFiDashboard() { const { wallet, provider } = useWallet(); const [balances, setBalances] = useState<TokenBalance[]>([]); const [loading, setLoading] = useState(false); const tokens = [ { address: '0x...', symbol: 'USDC', decimals: 6 }, { address: '0x...', symbol: 'USDT', decimals: 6 }, { address: '0x...', symbol: 'DAI', decimals: 18 }, ]; useEffect(() => { if (provider && wallet.address) { fetchBalances(); } }, [provider, wallet.address]); const fetchBalances = async () => { if (!provider || !wallet.address) return; setLoading(true); const results: TokenBalance[] = []; for (const token of tokens) { try { const signer = await provider.getSigner(); const contractService = new ContractService( token.address, ERC20_ABI, provider, signer ); const balance = await contractService.balanceOf(wallet.address); const formatted = formatUnits(balance, token.decimals); results.push({ symbol: token.symbol, balance: formatted, decimals: token.decimals, }); } catch (error) { console.error(`Error fetching ${token.symbol} balance:`, error); } } setBalances(results); setLoading(false); }; if (!wallet.isConnected) { return <div>Please connect your wallet</div>; } return ( <div className="defi-dashboard"> <h2>Your Portfolio</h2> {loading ? ( <p>Loading balances...</p> ) : ( <table> <thead> <tr> <th>Token</th> <th>Balance</th> </tr> </thead> <tbody> {balances.map((balance) => ( <tr key={balance.symbol}> <td>{balance.symbol}</td> <td>{parseFloat(balance.balance).toFixed(2)}</td> </tr> ))} </tbody> </table> )} </div> ); }
Testing Your DApp
Testing is crucial for production DApps:
typescript// src/tests/contractService.test.ts import { describe, it, expect, vi } from 'vitest'; import { ContractService } from '../services/contractService'; describe('ContractService', () => { it('should fetch balance correctly', async () => { const mockProvider = {} as any; const mockSigner = {} as any; const mockContract = { balanceOf: vi.fn().mockResolvedValue(BigInt(1000000000000000000)), }; const service = new ContractService( '0x...', [], mockProvider, mockSigner ); service['contract'] = mockContract; const balance = await service.balanceOf('0x...'); expect(balance).toBe(BigInt(1000000000000000000)); expect(mockContract.balanceOf).toHaveBeenCalledWith('0x...'); }); it('should handle transfer errors', async () => { const mockProvider = {} as any; const mockSigner = {} as any; const mockContract = { connect: vi.fn().mockReturnThis(), transfer: vi.fn().mockRejectedValue(new Error('Insufficient balance')), }; const service = new ContractService( '0x...', [], mockProvider, mockSigner ); service['contract'] = mockContract; await expect( service.transfer('0x...', BigInt(100)) ).rejects.toThrow('Insufficient balance'); }); });
Deployment Checklist
Before deploying your DApp to production:
- 1Security Audit
- Review all contract interactions - Validate user inputs - Check for reentrancy vulnerabilities
- 1Error Handling
- All transactions wrapped in try-catch - User-friendly error messages - Proper error logging
- 1Gas Optimization
- Estimate gas before transactions - Use optimal gas prices - Batch operations when possible
- 1User Experience
- Loading states for all async operations - Transaction progress indicators - Clear success/error feedback
- 1Testing
- Unit tests for all services - Integration tests for contracts - Testnet deployment
Conclusion
Ethers.js v6 provides everything you need to build production-ready DApps in 2025. By following these patterns and best practices, you can create robust, user-friendly decentralized applications that stand up to real-world use.
Start with simple wallet connections, gradually add contract interactions, and always prioritize security and user experience. The Web3 ecosystem is evolving rapidly, and Ethers.js is at the forefront of this revolution.