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:
npm create vite@latest my-dapp -- --template react-ts
cd my-dapp
npm install ethers@6
npm install -D @types/nodeWallet Connection Pattern
The most critical aspect of any DApp is wallet connection. Here's a production-ready implementation:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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
// 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
// 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
// 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:
// 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:
// 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.