Web3 dApp Development — ethers.js, Hardhat, WalletConnect Guide
Web3 dApp development connects a frontend (React, Vue) to the blockchain through a provider like MetaMask, letting users interact with smart contracts directly from their browser.
What You’ll Learn
By the end of this tutorial, you’ll build a complete dApp frontend using ethers.js, connect wallets with MetaMask and WalletConnect, interact with smart contracts, handle events and gas estimation, and test with Hardhat.
Why Web3 dApps Matter
Traditional apps control user data on centralized servers. Web3 dApps give users ownership of their assets and data through self-custodial wallets. Every interaction is signed by the user and verified on-chain. Doda Browser includes a built-in Web3 wallet that lets users interact with dApps without installing MetaMask — making Web3 accessible to millions of users.
dApp Development Learning Path
flowchart LR
A[Ethereum & Smart Contracts] --> B[Web3 dApp Development]
B --> C{You Are Here}
C --> D[ethers.js]
C --> E[Wallet Connection]
C --> F[Smart Contract Interaction]
C --> G[Hardhat Testing]
D --> H[Providers & Signers]
E --> I[MetaMask / WalletConnect]
ethers.js vs web3.js
| Feature | ethers.js | web3.js |
|---|---|---|
| Bundle size | ~200KB gzipped | ~500KB gzipped |
| TypeScript support | Native | Community |
| ENS integration | Built-in | Manual |
| Provider abstraction | Clear separation (Provider/Signer) | Mixed |
| Gas estimation | Automatic | Manual |
| Popularity | Growing (preferred for new projects) | Legacy (larger ecosystem) |
Recommendation: Use ethers.js for new projects. It’s smaller, better typed, and more intuitive.
Setting Up a dApp Frontend
# Create a new dApp project
mkdir dodatech-dapp && cd dodatech-dapp
npm init -y
npm install ethers @walletconnect/web3-provider @web3modal/ethers
npm install vite react react-dom
npm install -D @types/react @types/react-dom typescriptWallet Connection with MetaMask
// hooks/useWallet.ts
// Wallet connection hook with MetaMask
import { useState, useEffect, useCallback } from 'react';
import { BrowserProvider, JsonRpcSigner } from 'ethers';
interface WalletState {
address: string | null;
chainId: number | null;
signer: JsonRpcSigner | null;
provider: BrowserProvider | null;
}
export function useWallet() {
const [wallet, setWallet] = useState<WalletState>({
address: null,
chainId: null,
signer: null,
provider: null,
});
const [error, setError] = useState<string | null>(null);
const [connecting, setConnecting] = useState(false);
const connect = useCallback(async () => {
if (!window.ethereum) {
setError('Please install MetaMask');
return;
}
setConnecting(true);
setError(null);
try {
const provider = new BrowserProvider(window.ethereum);
const accounts = await provider.send('eth_requestAccounts', []);
const signer = await provider.getSigner();
const network = await provider.getNetwork();
setWallet({
address: accounts[0],
chainId: Number(network.chainId),
signer,
provider,
});
} catch (err: any) {
setError(err.message || 'Failed to connect wallet');
} finally {
setConnecting(false);
}
}, []);
const disconnect = useCallback(() => {
setWallet({ address: null, chainId: null, signer: null, provider: null });
}, []);
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) disconnect();
else setWallet(prev => ({ ...prev, address: accounts[0] }));
};
const handleChainChanged = (chainId: string) => {
setWallet(prev => ({ ...prev, chainId: Number(chainId) }));
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
return () => {
window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum?.removeListener('chainChanged', handleChainChanged);
};
}, [disconnect]);
return { ...wallet, connect, disconnect, connecting, error };
}WalletConnect Integration
// hooks/useWalletConnect.ts
// WalletConnect integration with Web3Modal
import { createWeb3Modal, defaultConfig } from '@web3modal/ethers';
const projectId = 'YOUR_WALLETCONNECT_PROJECT_ID';
const modal = createWeb3Modal({
ethersConfig: defaultConfig({
metadata: {
name: 'DodaTech dApp',
description: 'DodaTech Web3 dApp Tutorial',
url: 'https://dodatech.io',
icons: ['https://dodatech.io/icon.svg'],
},
}),
chains: [
{
chainId: 1,
name: 'Ethereum',
currency: 'ETH',
explorerUrl: 'https://etherscan.io',
rpcUrl: 'https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY',
},
{
chainId: 11155111,
name: 'Sepolia',
currency: 'ETH',
explorerUrl: 'https://sepolia.etherscan.io',
rpcUrl: 'https://sepolia.g.alchemy.com/v2/YOUR_KEY',
},
],
projectId,
});
export function useWalletConnect() {
const [address, setAddress] = useState<string | null>(null);
const [provider, setProvider] = useState<any>(null);
const connect = async () => {
await modal.open();
const info = await modal.getWalletInfo();
if (info?.address) setAddress(info.address);
};
const disconnect = async () => {
await modal.close();
setAddress(null);
setProvider(null);
};
return { address, provider, connect, disconnect, modal };
}Smart Contract Interaction
// components/NFTMinter.tsx
// Complete dApp component for minting NFTs
import React, { useState } from 'react';
import { Contract, parseEther } from 'ethers';
import { useWallet } from '../hooks/useWallet';
const CONTRACT_ADDRESS = '0xYourContractAddress';
const ABI = [
'function mintNFT(address recipient, string memory tokenURI) public payable returns (uint256)',
'function mintPrice() public view returns (uint256)',
'function totalSupply() public view returns (uint256)',
'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)',
];
export function NFTMinter() {
const { address, signer, connect, connecting, error } = useWallet();
const [tokenURI, setTokenURI] = useState('');
const [minting, setMinting] = useState(false);
const [receipt, setReceipt] = useState<any>(null);
const mintNFT = async () => {
if (!signer || !address || !tokenURI) return;
setMinting(true);
setReceipt(null);
try {
const contract = new Contract(CONTRACT_ADDRESS, ABI, signer);
const price = await contract.mintPrice();
const gasEstimate = await contract.mintNFT.estimateGas(address, tokenURI, { value: price });
const tx = await contract.mintNFT(address, tokenURI, {
value: price,
gasLimit: gasEstimate * 120n / 100n,
});
const txReceipt = await tx.wait();
setReceipt({
hash: txReceipt.hash,
blockNumber: txReceipt.blockNumber,
gasUsed: txReceipt.gasUsed.toString(),
});
} catch (err: any) {
alert('Mint failed: ' + (err.reason || err.message));
} finally {
setMinting(false);
}
};
if (!address) {
return (
<div>
<h2>Mint Your NFT</h2>
<button onClick={connect} disabled={connecting}>
{connecting ? 'Connecting...' : 'Connect Wallet'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
return (
<div>
<h2>Mint Your NFT</h2>
<p>Wallet: {address.slice(0, 6)}...{address.slice(-4)}</p>
<input type="text" placeholder="IPFS URI of metadata" value={tokenURI}
onChange={(e) => setTokenURI(e.target.value)}
style={{ width: '100%', padding: '8px', marginBottom: '12px' }} />
<button onClick={mintNFT} disabled={minting || !tokenURI}>
{minting ? 'Minting...' : 'Mint NFT'}
</button>
{receipt && (
<div style={{ marginTop: '16px' }}>
<h3>Mint Successful!</h3>
<p>Hash: {receipt.hash}</p>
<p>Block: {receipt.blockNumber}</p>
<p>Gas Used: {receipt.gasUsed}</p>
</div>
)}
</div>
);
}Events and Subscriptions
// events.ts
// Listening to smart contract events
import { Contract, BrowserProvider } from 'ethers';
async function listenToEvents() {
const provider = new BrowserProvider(window.ethereum);
const contract = new Contract('0xYourContractAddress', [
'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)',
'event Mint(address indexed minter, uint256 tokenId, uint256 price)',
], provider);
contract.on('Transfer', (from, to, tokenId, event) => {
console.log('Transfer: ' + from + ' -> ' + to + ', Token #' + tokenId);
updateNFTGallery(tokenId, to);
});
const userAddress = '0xYourAddress';
const filter = contract.filters.Mint(userAddress);
contract.on(filter, (minter, tokenId, price) => {
console.log('User ' + minter + ' minted #' + tokenId + ' for ' + price + ' wei');
});
const pastEvents = await contract.queryFilter('Transfer', 10000000, 'latest');
console.log('Found ' + pastEvents.length + ' past Transfer events');
}Gas Estimation and Optimization
// gas.ts
// Gas estimation and management
import { Contract, parseEther, formatEther } from 'ethers';
async function estimateAndSend(signer, contract: Contract) {
const gasEstimate = await contract.mintNFT.estimateGas(
'0xAddress', 'ipfs://metadata-uri',
{ value: parseEther('0.01') }
);
console.log('Estimated: ' + gasEstimate.toString());
const feeData = await signer.provider.getFeeData();
const tx = await contract.mintNFT('0xAddress', 'ipfs://uri', {
value: parseEther('0.01'),
gasLimit: gasEstimate * 120n / 100n,
maxFeePerGas: feeData.maxFeePerGas,
maxPriorityFeePerGas: feeData.maxPriorityFeePerGas,
});
const receipt = await tx.wait();
console.log('Gas used: ' + receipt.gasUsed.toString());
return receipt;
}Hardhat Testing
// test/NFTMinter.test.ts
// Hardhat test for NFT contract
import { expect } from 'chai';
import { ethers } from 'hardhat';
describe('DodaNFT', function () {
async function deployContract() {
const [owner, user1, user2] = await ethers.getSigners();
const DodaNFT = await ethers.getContractFactory('DodaNFT');
const contract = await DodaNFT.deploy();
await contract.waitForDeployment();
return { contract, owner, user1, user2 };
}
it('should mint NFT with correct owner', async function () {
const { contract, owner } = await deployContract();
const ownerAddress = await owner.getAddress();
await contract.mintNFT(ownerAddress, 'ipfs://test-uri', {
value: ethers.parseEther('0.01'),
});
expect(await contract.ownerOf(1)).to.equal(ownerAddress);
expect(await contract.tokenURI(1)).to.equal('ipfs://test-uri');
expect(await contract.totalSupply()).to.equal(1);
});
it('should reject mint without payment', async function () {
const { contract, owner } = await deployContract();
const addr = await owner.getAddress();
await expect(
contract.mintNFT(addr, 'ipfs://uri', { value: 0 })
).to.be.revertedWith('Insufficient payment');
});
it('should reject mint beyond max supply', async function () {
const { contract, owner } = await deployContract();
const addr = await owner.getAddress();
for (let i = 0; i < 100; i++) {
await contract.mintNFT(addr, 'ipfs://uri-' + i, {
value: ethers.parseEther('0.01'),
});
}
await expect(
contract.mintNFT(addr, 'ipfs://uri-101', {
value: ethers.parseEther('0.01'),
})
).to.be.revertedWith('Max supply reached');
});
it('should transfer NFT between users', async function () {
const { contract, owner, user1 } = await deployContract();
const ownerAddr = await owner.getAddress();
const user1Addr = await user1.getAddress();
await contract.mintNFT(ownerAddr, 'ipfs://uri', {
value: ethers.parseEther('0.01'),
});
await contract.transferFrom(ownerAddr, user1Addr, 1);
expect(await contract.ownerOf(1)).to.equal(user1Addr);
});
});dApp Architecture
flowchart TB
subgraph "Frontend (React/Next.js)"
UI[User Interface]
WC[Wallet Connection
MetaMask / WalletConnect]
TX[Transaction Manager]
EV[Event Listener]
ST[State Management]
end
subgraph "Blockchain"
RPC[RPC Node
Infura / Alchemy]
SC1[Smart Contract A]
SC2[Smart Contract B]
end
subgraph "Backend Services"
API[API Server]
DB[(Database)]
IPFS[(IPFS Node)]
end
UI --> WC
WC --> RPC
WC --> SC1
TX --> SC1
TX --> SC2
EV --> SC1
EV --> UI
ST --> UI
API --> DB
API --> IPFS
UI --> API
Common dApp Development Mistakes
1. Using BrowserProvider on the Server
BrowserProvider only works in browser environments with window.ethereum. Trying to use it in Next.js server-side rendering will crash. Use JsonRpcProvider for server-side reads and wrap browser code in useEffect.
2. Not Checking Network
A user on Ethereum mainnet trying to interact with a Sepolia contract gets cryptic errors. Always check network.chainId and prompt users to switch networks via wallet_switchEthereumChain.
3. Forgetting Gas Buffer
estimateGas returns an exact estimate that may be too low during network congestion. Always add a 10-20% buffer: gasLimit: estimated * 120n / 100n.
4. Not Handling Wallet Disconnection
Users switch accounts or networks while your dApp is open. Listen for accountsChanged and chainChanged events to update state. If the user disconnects, reset the UI gracefully.
5. Hard-Coding Contract Addresses
Different chains have different contract addresses. Use environment variables and a config object per chain:
const CONFIGS = {
1: { contractAddress: '0xMainnet...', rpcUrl: 'https://eth-mainnet...' },
11155111: { contractAddress: '0xSepolia...', rpcUrl: 'https://sepolia...' },
};6. Not Handling Transaction Reverts
A transaction can revert after being sent (e.g., out of gas, slippage). Always wrap tx.wait() in try/catch and decode the revert reason using contract.interface.parseError().
7. Ignoring Bundle Size
Importing the full ethers.js increases bundle size. Use tree-shaking and import only what you need: import { Contract, BrowserProvider } from 'ethers'.
Practice Questions
1. What is the difference between ethers.js and web3.js?
ethers.js is smaller (~200KB vs ~500KB), has better TypeScript support, built-in ENS resolution, and clearer separation between Provider (read-only) and Signer (write-capable). web3.js has a larger ecosystem but is heavier and less type-safe.
2. How does MetaMask inject a provider into the browser?
MetaMask injects an ethereum object into the global window object at window.ethereum. This object implements the EIP-1193 provider interface, exposing request(), on(), and removeListener() methods.
3. What is the purpose of estimateGas and why should you add a buffer?
estimateGas simulates a transaction to determine gas needed. The estimate can be too low during network congestion. Adding a 10-20% buffer prevents out-of-gas errors while avoiding overpaying.
4. How does WalletConnect differ from MetaMask?
MetaMask is a browser extension that injects a provider. WalletConnect is a protocol that connects wallets via a QR code scan or deep link, supporting mobile wallets (Rainbow, Trust Wallet) and desktop wallets.
5. Challenge: Build a dApp that lets users stake ETH in a staking contract, view their stake, and claim rewards. Include event listeners for Deposit and Withdraw events.
Use ethers.js with MetaMask connection. Create StakingContract with stake(), unstake(), claimRewards(), and getStake() functions. Listen for Deposit and Withdraw events to update UI in real-time. Add gas estimation with 15% buffer and EIP-1559 fee data.
Mini Project: dApp Starter Template
// dapp-starter.ts
// Minimal dApp starter with ethers.js
import { BrowserProvider, Contract } from 'ethers';
declare global {
interface Window { ethereum?: any; }
}
interface DAppState {
address: string | null;
chainId: number | null;
provider: BrowserProvider | null;
connected: boolean;
}
class DApp {
state: DAppState = { address: null, chainId: null, provider: null, connected: false };
async connect(): Promise<DAppState> {
if (!window.ethereum) throw new Error('No wallet found');
const provider = new BrowserProvider(window.ethereum);
const accounts = await provider.send('eth_requestAccounts', []);
const network = await provider.getNetwork();
this.state = {
address: accounts[0],
chainId: Number(network.chainId),
provider,
connected: true,
};
return this.state;
}
async getContract(address: string, abi: any[]) {
if (!this.state.provider) throw new Error('Not connected');
const signer = await this.state.provider.getSigner();
return new Contract(address, abi, signer);
}
onAccountChange(callback: (address: string) => void) {
window.ethereum?.on('accountsChanged', (accounts: string[]) => {
if (accounts.length > 0) callback(accounts[0]);
});
}
}
// Usage
const dapp = new DApp();
dapp.connect().then(state => {
console.log('Connected: ' + state.address);
dapp.onAccountChange(addr => console.log('Account changed to: ' + addr));
}).catch(err => console.error('Connection failed:', err));Related Concepts
FAQ
What’s Next
You now understand Web3 dApp development with ethers.js, MetaMask, WalletConnect, and Hardhat. Next, explore DeFi to build financial dApps and smart contract security to write secure contracts.
- Practice daily — Connect MetaMask to a test dApp and call a view function on Sepolia
- Build a project — Create a full dApp with React + ethers.js that lets users mint and transfer NFTs
- Deploy — Deploy your dApp on Vercel and test with real users on Sepolia testnet
Remember: every expert was once a beginner. Keep building!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro