Build a DApp with Ethers.js & MetaMask: Developer Guide
Published July 27, 2026 · Editorial policy

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
- The Injected Provider: MetaMask injects an
EIP-1193compliant provider object into the globalwindow.ethereumscope. - The Ethers Abstraction: Ethers.js wraps this low-level provider via
BrowserProvider, transforming raw RPC payloads into strongly-typed JavaScript/TypeScript objects. - Signer & Provider Separation: We isolate read-only operations using a
Providerand state-mutating transactions using aSigner.
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:
- Never Trust State Blindly: Always handle asynchronous loading states. UI components must reflect pending, successful, and failed transaction states clearly.
- 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. - Strict Type Safety: Avoid using
anytypes for Ethereum addresses, BigInt values, and transaction receipts. Use Ethers.js built-in types (TransactionResponse,TransactionReceipt,Interface). - 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.


