Skip to content

Building Web3 Frontends with ethers.js — Complete Guide

DodaTech 7 min read

In this tutorial, you'll learn about Building Web3 Frontends with ethers.js. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

This tutorial teaches you to build Web3 frontends with ethers.js — you will connect to a user's wallet, read data from Ethereum smart contracts, send transactions, listen for events, and handle common wallet integration challenges.

Why It Matters

Smart contracts are invisible without a frontend. Users interact with Ethereum through web applications that connect their wallets, read contract state, and submit transactions. Ethers.js is the most widely used library for this, powering interfaces for Uniswap, OpenSea, and thousands of dApps.

Real-World Use

A DeFi dashboard reads token balances and positions from multiple contracts. An NFT marketplace displays owned tokens and handles minting. A DAO voting interface lets users delegate votes and cast proposals — all built with ethers.js connecting to Ethereum nodes.

flowchart LR
    A[User Browser] --> B[Frontend React App]
    B --> C[ethers.js Library]
    C --> D[Wallet Browser Extension]
    D --> E[Ethereum RPC Provider]
    C --> F["Infura/Alchemy Node"]
    F --> G[Ethereum Network]
    G --> H[Smart Contract]
    D --> G
    style A fill:#4CAF50,color:#fff
    style C fill:#2196F3,color:#fff
    style D fill:#FF9800,color:#fff
    style G fill:#9C27B0,color:#fff

Setting Up ethers.js

Install ethers.js and create a provider that connects to both a node and the user's wallet.

import { ethers } from 'ethers';

// Connect to Ethereum node
const provider = new ethers.JsonRpcProvider(
  'https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY'
);

// Connect to user's wallet via browser extension
async function connectWallet() {
  if (!window.ethereum) {
    console.log('Please install MetaMask');
    return null;
  }

  const browserProvider = new ethers.BrowserProvider(window.ethereum);
  const signer = await browserProvider.getSigner();
  const address = await signer.getAddress();
  const balance = await browserProvider.getBalance(address);

  console.log(`Connected: ${address}`);
  console.log(`Balance: ${ethers.formatEther(balance)} ETH`);

  return { provider: browserProvider, signer, address };
}

connectWallet();

Expected output (after user connects wallet):

Connected: 0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18
Balance: 1.23456789 ETH

Reading Contract Data

Interact with smart contracts by creating a Contract instance using the ABI and contract address.

const COUNTER_ABI = [
  'function getCount() view returns (uint256)',
  'function increment()',
  'event CountIncremented(uint256 newCount)',
];

const CONTRACT_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678';

async function readCounter() {
  const provider = new ethers.JsonRpcProvider(
    'https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY'
  );

  const contract = new ethers.Contract(
    CONTRACT_ADDRESS,
    COUNTER_ABI,
    provider
  );

  const count = await contract.getCount();
  console.log(`Current count: ${count}`);

  // Also read token info from an ERC-20
  const USDC_ADDRESS = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238';
  const ERC20_ABI = [
    'function balanceOf(address owner) view returns (uint256)',
    'function decimals() view returns (uint8)',
    'function symbol() view returns (string)',
  ];
  const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
  const decimals = await usdc.decimals();
  const symbol = await usdc.symbol();
  console.log(`USDC decimals: ${decimals}, symbol: ${symbol}`);
}

Expected output:

Current count: 42
USDC decimals: 6, symbol: USDC

All read operations are free — they execute against the connected node without requiring gas.

Writing to Contracts

Writing requires a signer (the user's private key) and pays gas.

async function incrementCounter() {
  if (!window.ethereum) {
    alert('Please install MetaMask');
    return;
  }

  const provider = new ethers.BrowserProvider(window.ethereum);
  const signer = await provider.getSigner();

  const contract = new ethers.Contract(
    CONTRACT_ADDRESS,
    COUNTER_ABI,
    signer
  );

  try {
    const tx = await contract.increment();
    console.log(`Transaction sent: ${tx.hash}`);
    console.log('Waiting for confirmation...');

    const receipt = await tx.wait();
    console.log(`Confirmed in block: ${receipt.blockNumber}`);
    console.log(`Gas used: ${receipt.gasUsed.toString()}`);
  } catch (error) {
    console.error('Transaction failed:', error.reason || error.message);
  }
}

Expected output:

Transaction sent: 0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890
Waiting for confirmation...
Confirmed in block: 4567890
Gas used: 43721

The Transaction must be signed by the user in MetaMask. The wait() call resolves once the Transaction has been included in a block.

Listening to Events

Etherscan-style real-time updates use event listeners.

async function listenToEvents() {
  const provider = new ethers.JsonRpcProvider(
    'https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY'
  );

  const contract = new ethers.Contract(
    CONTRACT_ADDRESS,
    COUNTER_ABI,
    provider
  );

  // Listen for 'CountIncremented' events from the last block
  contract.on('CountIncremented', (newCount, event) => {
    console.log(`Count incremented to: ${newCount}`);
    console.log(`Block: ${event.blockNumber}`);
    console.log(`Transaction: ${event.transactionHash}`);
  });

  // Get past events (last 1000 blocks)
  const filter = contract.filters.CountIncremented();
  const events = await contract.queryFilter(filter, -1000, 'latest');
  console.log(`Found ${events.length} recent events`);
  events.slice(-3).forEach(e => {
    console.log(`Block ${e.blockNumber}: count = ${e.args[0]}`);
  });
}

Expected output:

Found 15 recent events
Block 4567880: count = 41
Block 4567890: count = 42
Block 4567900: count = 43

The event listener stays active and logs new events as they occur. The queryFilter method retrieves historical events for initial page load.

Handling Network Changes

Users can switch networks in their wallet. Your dApp must handle this gracefully.

class DAppConnection {
  constructor() {
    this.provider = null;
    this.signer = null;
    this.account = null;
    this.chainId = null;
  }

  async initialize() {
    if (!window.ethereum) {
      throw new Error('Wallet not detected');
    }

    this.provider = new ethers.BrowserProvider(window.ethereum);
    const accounts = await this.provider.send('eth_requestAccounts', []);
    this.account = accounts[0];
    this.chainId = await this.provider.send('eth_chainId', []);

    this.setupListeners();
    return this.account;
  }

  setupListeners() {
    window.ethereum.on('accountsChanged', (accounts) => {
      if (accounts.length === 0) {
        console.log('Wallet disconnected');
        this.account = null;
      } else {
        this.account = accounts[0];
        console.log(`Account changed to: ${this.account}`);
      }
    });

    window.ethereum.on('chainChanged', (chainId) => {
      console.log(`Network changed to: ${parseInt(chainId, 16)}`);
      window.location.reload();
    });
  }

  async switchNetwork(chainId) {
    try {
      await window.ethereum.request({
        method: 'wallet_switchEthereumChain',
        params: [{ chainId: `0x${chainId.toString(16)}` }],
      });
    } catch (error) {
      if (error.code === 4902) {
        console.log('Network not available, please add it');
      }
    }
  }
}

Expected behavior: initialize() connects the wallet and sets up listeners. When the user changes accounts in MetaMask, the accountsChanged handler updates the DApp state. When the user switches networks, the page reloads to reconnect cleanly with the new chain.

Common Errors

  1. Not checking for window.<a href="/cryptocurrency/ethereum/">Ethereum</a> — Users without MetaMask should see a friendly install prompt, not a JavaScript error.
  2. Using the wrong provider for writes — Read operations use a JsonRpcProvider, but writes must use a BrowserProvider with a signer.
  3. Ignoring Transaction failures — A Transaction can be sent successfully but fail during execution. Always check receipt.status.
  4. Hardcoding chain IDs — Your DApp should detect and handle unsupported networks gracefully, with clear user messaging.
  5. Not handling provider disconnection — Rate limits, network issues, and wallet disconnections should trigger automatic reconnection or clear user feedback.

Practice Questions

  1. What is the difference between a Provider and a Signer in ethers.js? A Provider connects to an Ethereum node for reading data. A Signer extends Provider with a private key for signing transactions. Use Provider for view calls and Signer (via getSigner()) for transactions that modify state.

  2. Why do you need an ABI to interact with a contract? The ABI (Application Binary Interface) describes the contract's functions, parameters, and events in JSON format. Without it, ethers.js cannot encode function calls or decode contract responses.

  3. How do you estimate gas before sending a Transaction? Use contract.functionName.estimateGas(params) to get a gas estimate, then add a buffer (20-50%) for safety. You can also pass gasLimit explicitly in Transaction options.

Frequently Asked Questions

{{< faq question="Should I use ethers.js or web3.js for my dApp?">}} Ethers.js is recommended for new projects — smaller bundle size (84KB vs 600KB), better TypeScript support, more intuitive API, and modern async/await patterns. Web3.js has broader legacy adoption and more plugins. Both work with MetaMask and other wallets. The JavaScript ecosystem generally prefers ethers.js for new development. {{< /faq >}}

{{< faq question="How do I handle large numbers from smart contracts?">}} Ethereum uses uint256 which exceeds JavaScript's safe integer range. Always use ethers.js's BigInt type (or the built-in BigInt in modern JS) for arithmetic. Use ethers.formatEther() for display and ethers.parseEther() for input. Never parse user balances or token amounts with parseInt or Number. {{< /faq >}}

{{< faq question="How can I make my DApp work without requiring users to switch networks?">}} Use a multi-chain configuration: detect the user's current network, check if your contract exists on that network, and route accordingly. Use environment variables for contract addresses per network. For networks without your contracts, suggest the user switch or provide fallback functionality via a read-only provider connected to a supported network.{{< /faq >}}

Next Steps

Smart Contract Development with Solidity — Build the contracts your frontend will connect to.

Testing Smart Contracts with Hardhat — Test contracts before integrating with the frontend.

DeFi Explained — Examine real DeFi frontend architectures.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro