Web3

Build a DApp with Ethers.js & MetaMask: Developer Guide

Tech Setup4 min readUpdated August 3, 2026
TS

Tech Setup

Published July 27, 2026 · Editorial policy

Build a DApp with Ethers.js & MetaMask: Developer Guide

Introduction

Building decentralized applications (DApps) requires bridging traditional web frontend architecture with cryptographic state machines. For senior developers transitioning into Web3, the standard stack relies on MetaMask for key management and Ethers.js for JSON-RPC communication, state reading, and transaction signing.

Unlike older abstractions that hid network mechanics, Ethers.js provides a clean, concise API that maps directly to Ethereum JSON-RPC specifications. In this technical guide, we will build a complete, production-ready DApp architecture using vanilla TypeScript, Vite, MetaMask, and Ethers.js v6. We will connect to an injected provider, handle chain switching, read state from a smart contract, and dispatch signed transactions.


Prerequisites and Architectural Overview

Before initializing the workspace, ensure your local environment is configured for modern Web3 development.

System Requirements

  • Node.js $\ge$ 18.x LTS
  • pnpm or npm
  • MetaMask browser extension installed with testnet funds (e.g., Sepolia ETH)

Architecture Flow

  1. The Injected Provider: MetaMask injects an EIP-1193 compliant provider object into the global window.ethereum scope.
  2. The Ethers Abstraction: Ethers.js wraps this low-level provider via BrowserProvider, transforming raw RPC payloads into strongly-typed JavaScript/TypeScript objects.
  3. Signer & Provider Separation: We isolate read-only operations using a Provider and state-mutating transactions using a Signer.

Project Setup & Dependency Installation

Initialize a new Vite project with TypeScript to guarantee type safety when handling hexadecimal transaction hashes, big numbers, and contract ABIs.

pnpm create vite dapp-guide --template vanilla-ts
cd dapp-guide
pnpm install

Install Ethers.js v6. Avoid legacy v5 packages (ethers), as v6 introduces critical breaking changes including native BigInt support, modular imports, and refactored class names.

pnpm install ethers

Verify your package.json includes ethers under dependencies. Next, clear out the default Vite boilerplate and structure your workspace:

dapp-guide/
├── src/
│   ├── abi/
│   │   └── Counter.json
│   ├── contracts/
│   └── main.ts
├── index.html
├── package.json
└── tsconfig.json

Establishing the MetaMask Connection

The first step in any DApp is requesting user authorization via MetaMask. We need to check if window.ethereum is present, prompt the user for account access using eth_requestAccounts, and instantiate the Ethers BrowserProvider.

Create src/main.ts and set up the core connection logic:

import { ethers } from 'ethers';

// Extend the Window interface to recognize the injected ethereum object
declare global {
  interface Window {
    ethereum?: any;
  }
}

let provider: ethers.BrowserProvider | null = null;
let signer: ethers.Signer | null = null;

const connectButton = document.getElementById('connectWallet') as HTMLButtonElement;
const walletStatus = document.getElementById('walletStatus') as HTMLDivElement;

async function initializeProvider() {
  if (!window.ethereum) {
    walletStatus.innerText = 'MetaMask is not installed. Please install it to use this DApp.';
    return false;
  }

  try {
    // Wrap the injected EIP-1193 provider with Ethers.js BrowserProvider
    provider = new ethers.BrowserProvider(window.ethereum);
    return true;
  } catch (error) {
    console.error('Failed to initialize provider:', error);
    return false;
  }
}

async function connectWallet() {
  const isReady = await initializeProvider();
  if (!isReady || !provider) return;

  try {
    // Request account access
    await provider.send('eth_requestAccounts', []);
    
    // Retrieve the signer instance
    signer = await provider.getSigner();
    const address = await signer.getAddress();
    const network = await provider.getNetwork();

    walletStatus.innerHTML = `
      <p>Connected: <code>${address}</code></p>
      <p>Chain ID: ${network.chainId}</p>
    `;
    connectButton.innerText = 'Connected';
    connectButton.disabled = true;

    // Listen for account changes
    window.ethereum.on('accountsChanged', handleAccountsChanged);
    // Listen for chain changes
    window.ethereum.on('chainChanged', handleChainChanged);

  } catch (error: any) {
    console.error('User rejected connection:', error);
    walletStatus.innerText = `Connection failed: ${error.message}`;
  }
}

function handleAccountsChanged(accounts: string[]) {
  if (accounts.length === 0) {
    walletStatus.innerText = 'Please connect to MetaMask.';
    connectButton.innerText = 'Connect Wallet';
    connectButton.disabled = false;
    signer = null;
  } else {
    connectWallet();
  }
}

function handleChainChanged(_chainId: string) {
  // Best practice: Reload the page on network change to reset application state
  window.location.reload();
}

connectButton.addEventListener('click', connectWallet);

Update your index.html to provide the required DOM hooks:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DApp Developer Guide</title>
  </head>
  <body>
    <main style="padding: 2rem; font-family: sans-serif;">
      <h1>Ethers.js & MetaMask DApp</h1>
      <button id="connectWallet">Connect Wallet</button>
      <div id="walletStatus" style="margin-top: 1rem;"></div>
    </main>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Handling Network Validation & Chain Switching

DApps frequently require specific networks (e.g., Ethereum Mainnet, Optimism, or Sepolia testnet). If a user is on the wrong chain, your contract calls will fail. We must implement programmatic chain switching using wallet_switchEthereumChain and fall back to wallet_addEthereumChain if the network is absent from their MetaMask profile.

Add this utility function to handle network enforcement:

const SEPOLIA_CHAIN_ID = '0xaa36a7'; // Chain ID 11155111 in hex

async function ensureCorrectNetwork(targetChainId: string): Promise<boolean> {
  if (!window.ethereum) return false;

  try {
    const currentChainId = await window.ethereum.request({ method: 'eth_chainId' });
    
    if (currentChainId === targetChainId) {
      return true;
    }

    // Attempt to switch networks
    await window.ethereum.request({
      method: 'wallet_switchEthereumChain',
      params: [{ chainId: targetChainId }],
    });
    return true;
  } catch (switchError: any) {
    // This error code indicates that the chain has not been added to MetaMask.
    if (switchError.code === 4902) {
      try {
        await window.ethereum.request({
          method: 'wallet_addEthereumChain',
          params: [
            {
              chainId: targetChainId,
              chainName: 'Sepolia Test Network',
              nativeCurrency: {
                name: 'SepoliaETH',
                symbol: 'SepoliaETH',
                decimals: 18,
              },
              rpcUrls: ['https://rpc.sepolia.org'],
              blockExplorerUrls: ['https://sepolia.etherscan.io'],
            },
          ],
        });
        return true;
      } catch (addError) {
        console.error('Failed to add the network:', addError);
        return false;
      }
    }
    console.error('Failed to switch the network:', switchError);
    return false;
  }
}

Integrate ensureCorrectNetwork(SEPOLIA_CHAIN_ID) directly inside your connectWallet execution pipeline immediately after fetching the provider.


Interacting with Smart Contracts

To interact with an on-chain smart contract, Ethers.js requires two parameters: the Contract Address and the ABI (Application Binary Interface).

1. Defining the Contract ABI

Create src/abi/Counter.json representing a simple stateful contract with a getCount() view function and an increment() state-mutating function:

[
  {
    "inputs": [],
    "name": "getCount",
    "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "increment",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]

2. Instantiating and Querying Contract State

When executing read-only operations (view or pure), pass the provider instance. This prevents MetaMask from prompting the user for approval and executes the call locally via RPC without incurring gas fees.

import CounterABI from './abi/Counter.json';

const CONTRACT_ADDRESS = '0xYourDeployedContractAddressHere';

async function fetchContractData() {
  if (!provider) return;

  try {
    // Read-only contract instance requires a provider
    const readContract = new ethers.Contract(CONTRACT_ADDRESS, CounterABI, provider);
    
    // Call the view method
    const currentCount = await readContract.getCount();
    
    console.log('Current Count (BigInt):', currentCount);
    // Ethers v6 natively returns BigInt. Convert for UI rendering:
    document.getElementById('countDisplay')!.innerText = currentCount.toString();
  } catch (error) {
    console.error('Error reading contract state:', error);
  }
}

3. Executing State-Mutating Transactions

When executing functions that modify storage (nonpayable or payable), pass the signer. This prompts MetaMask to simulate the transaction, calculate gas limits, and request user signature approval.

async function incrementCounter() {
  if (!signer) {
    alert('Please connect your wallet first.');
    return;
  }

  try {
    // Write contract instance requires a signer
    const writeContract = new ethers.Contract(CONTRACT_ADDRESS, CounterABI, signer);

    const txButton = document.getElementById('incrementBtn') as HTMLButtonElement;
    txButton.innerText = 'Confirm in MetaMask...';
    txButton.disabled = true;

    // Dispatch transaction
    const tx = await writeContract.increment();
    
    console.log(`Transaction broadcasted: ${tx.hash}`);
    txButton.innerText = 'Waiting for confirmation...';

    // Wait for 1 confirmation block
    const receipt = await tx.wait();
    console.log('Transaction mined in block:', receipt.blockNumber);

    txButton.innerText = 'Increment';
    txButton.disabled = false;

    // Refresh state
    await fetchContractData();

  } catch (error: any) {
    console.error('Transaction failed:', error);
    alert(`Transaction rejected: ${error.reason || error.message}`);
    
    const txButton = document.getElementById('incrementBtn') as HTMLButtonElement;
    txButton.innerText = 'Increment';
    txButton.disabled = false;
  }
}

Handling Transaction Lifecycle and Gas Estimation

Production-grade DApps must handle the asynchronous nature of Ethereum transactions gracefully. A transaction passes through several states: broadcasted to the mempool, included in a block, and finalized.

Gas Optimization & Estimation

Before sending a transaction, you can programmatically estimate the gas cost to prevent out-of-gas errors and inform users of precise transaction fees.

async function estimateGasForIncrement() {
  if (!signer || !provider) return;

  try {
    const writeContract = new ethers.Contract(CONTRACT_ADDRESS, CounterABI, signer);
    
    // Estimate gas units required
    const estimatedGas = await writeContract.increment.estimateGas();
    
    // Fetch current fee data (gas price / base fee + priority fee)
    const feeData = await provider.getFeeData();

    if (feeData.gasPrice) {
      const estimatedCostWei = estimatedGas * feeData.gasPrice;
      const estimatedCostEth = ethers.formatEther(estimatedCostWei);
      
      console.log(`Estimated Transaction Fee: ${estimatedCostEth} ETH`);
    }
  } catch (error) {
    console.error('Gas estimation failed:', error);
  }
}

Best Practices & Security Considerations

When deploying DApps to production targeting Tier-1 users, keep these senior engineering principles in mind:

  1. Never Trust State Blindly: Always handle asynchronous loading states. UI components must reflect pending, successful, and failed transaction states clearly.
  2. Handle Reorganizations (Reorgs): Waiting for a single block confirmation (tx.wait()) is often sufficient for testnets, but high-value financial DApps on mainnet should wait for 6 to 12 confirmations to protect against chain reorganizations.
  3. Strict Type Safety: Avoid using any types for Ethereum addresses, BigInt values, and transaction receipts. Use Ethers.js built-in types (TransactionResponse, TransactionReceipt, Interface).
  4. Memory Leaks with Event Listeners: If your DApp subscribes to contract events (e.g., contract.on('Incremented', ...)), ensure you unmount or remove listeners when components unmount to prevent memory leaks in Single Page Applications (SPAs).

Conclusion

You have successfully built a decentralized application architecture using Ethers.js v6 and MetaMask. By cleanly separating read operations (via BrowserProvider) from write operations (via Signer), enforcing network compatibility, and managing the asynchronous transaction lifecycle, you have laid a robust foundation for building complex Web3 frontends.

From here, you can scale this architecture by integrating state management libraries (like Zustand or Redux Toolkit) and adopting modern UI component libraries to handle complex multi-chain DApp ecosystems.