DApp Frontend
A smart contract without a frontend is like a database without an application — technically functional but useless to end users. This module teaches you how to build a complete DApp frontend: connecting wallets, reading on-chain data, sending transactions, and reacting to blockchain events in real time.
Recap: From Testing to Frontend
In Module 11 you learned to test smart contracts thoroughly. Your contracts are now battle-tested and ready for deployment. But a tested contract sitting on the blockchain is invisible to users. Nobody will interact with it by crafting raw transactions in a terminal. You need a frontend.
What Is a DApp?
A DApp (Decentralized Application) is the combination of three layers: a frontend (HTML/CSS/JS running in the browser), a wallet (MetaMask or similar, which holds the user's private keys), and a smart contract (deployed on-chain). The frontend never touches private keys — it asks the wallet to sign transactions on behalf of the user.
DApp Architecture
The Read/Write Flow
Reads are free and instant — your frontend calls a view function via a public RPC node, no wallet needed. Writes cost gas and require the user's signature — your frontend builds the transaction, MetaMask signs it, and the signed transaction is broadcast to the network. The user sees a MetaMask popup for every write operation.
No Framework Required
This module uses vanilla HTML, CSS, and JavaScript. No React, no Vue, no Next.js. The goal is to understand the fundamentals — how window.ethereum works, how viem talks to the chain, how transactions flow. Once you understand these primitives, adopting any framework is trivial.
Wallet Connection
The first thing any DApp does is connect to the user's wallet. Without a wallet, you can read blockchain data but you cannot write. MetaMask is the most widely used browser wallet — it injects a global window.ethereum object that your frontend uses to communicate.
MetaMask & window.ethereum
When MetaMask is installed, it injects an Ethereum provider into every page at window.ethereum. This object implements the EIP-1193 standard — a universal interface for wallet communication. Your code calls methods on this provider; MetaMask handles the rest (key management, signing, broadcasting).
Detecting the Wallet
Before doing anything, check if a wallet is available. If window.ethereum is undefined, the user either does not have MetaMask installed or is using an unsupported browser.
if (typeof window.ethereum === "undefined") {
alert("Please install MetaMask to use this DApp.");
} else {
console.log("Wallet detected!");
}Requesting Account Access
Calling eth_requestAccounts triggers the MetaMask popup asking the user to connect. If the user approves, you receive an array of account addresses. If they reject, the promise is rejected.
async function connectWallet() {
try {
const accounts = await window.ethereum.request({
method: "eth_requestAccounts"
});
const userAddress = accounts[0];
console.log("Connected:", userAddress);
return userAddress;
} catch (error) {
console.error("User rejected connection:", error);
}
}Listening for Wallet Events
Users can switch accounts or change networks at any time. Your DApp must react to these changes. MetaMask emits accountsChanged when the user switches accounts and chainChanged when they switch networks.
window.ethereum.on("accountsChanged", (accounts) => {
if (accounts.length === 0) {
console.log("Wallet disconnected");
} else {
console.log("Switched to:", accounts[0]);
}
});
window.ethereum.on("chainChanged", (chainId) => {
console.log("Network changed to:", chainId);
// Reload the page to reset all state
window.location.reload();
});Switching Chains
If your DApp runs on a specific network (e.g. Sepolia testnet), you can request MetaMask to switch chains programmatically using wallet_switchEthereumChain. If the chain is not yet added to MetaMask, you catch the error and call wallet_addEthereumChain instead.
async function switchToSepolia() {
try {
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: "0xaa36a7" }] // Sepolia
});
} catch (error) {
if (error.code === 4902) {
await window.ethereum.request({
method: "wallet_addEthereumChain",
params: [{
chainId: "0xaa36a7",
chainName: "Sepolia Testnet",
rpcUrls: ["https://rpc.sepolia.org"],
nativeCurrency: { name: "ETH", symbol: "ETH", decimals: 18 },
blockExplorerUrls: ["https://sepolia.etherscan.io"]
}]
});
}
}
}Connection Checklist
A robust wallet connection handles: 1. Detection (is MetaMask installed?), 2. Connection (request accounts), 3. Account changes (user switches wallet), 4. Network changes (user switches chain), 5. Chain enforcement (switch to the correct network). Miss any of these and your DApp will break in production.
viem: The Blockchain Client
You need a library to encode function calls, decode return values, and manage the communication between your frontend and the blockchain. viem is the modern standard — TypeScript-first, tree-shakeable, and significantly smaller and faster than ethers.js.
Why viem over ethers.js?
Two Clients, Two Roles
viem separates concerns with two client types. A PublicClient talks to a public RPC node — it reads data, fetches balances, calls view functions. No wallet needed. A WalletClient talks to MetaMask — it signs and sends transactions. Every write operation goes through the WalletClient.
Creating a PublicClient
The PublicClient connects to any RPC endpoint. It handles all read operations: contract reads, balance queries, block data, transaction receipts.
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
const publicClient = createPublicClient({
chain: sepolia,
transport: http("https://rpc.sepolia.org")
});
// Read the current block number
const blockNumber = await publicClient.getBlockNumber();
console.log("Current block:", blockNumber);Creating a WalletClient
The WalletClient wraps the MetaMask provider (window.ethereum). It is used exclusively for write operations — sending transactions that modify on-chain state.
import { createWalletClient, custom } from "viem";
import { sepolia } from "viem/chains";
const walletClient = createWalletClient({
chain: sepolia,
transport: custom(window.ethereum)
});
// Get connected accounts
const [address] = await walletClient.getAddresses();
console.log("Wallet address:", address);Transports: HTTP vs WebSocket
HTTP transport (http()) uses standard request/response — good for reads and writes, simple to set up. WebSocket transport (webSocket()) keeps a persistent connection — required for real-time event subscriptions. Use HTTP for basic DApps; add WebSocket when you need live event feeds.
import { createPublicClient, http, webSocket } from "viem";
import { sepolia } from "viem/chains";
// HTTP — request/response
const httpClient = createPublicClient({
chain: sepolia,
transport: http("https://rpc.sepolia.org")
});
// WebSocket — persistent connection for events
const wsClient = createPublicClient({
chain: sepolia,
transport: webSocket("wss://sepolia.infura.io/ws/v3/YOUR_KEY")
});Reading Contract Data
Reading is the most common operation in any DApp — displaying balances, token names, allowances, vote counts. All reads are free (no gas), instant, and require no wallet connection. You just need a PublicClient and the contract's ABI.
publicClient.readContract()
The readContract method calls a view or pure function on a smart contract. You pass the contract address, ABI, function name, and arguments. viem encodes the call, sends it to the RPC node, and decodes the response — all type-safe.
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
import { abi } from "./MyToken-abi.js";
const publicClient = createPublicClient({
chain: sepolia,
transport: http("https://rpc.sepolia.org")
});
const CONTRACT = "0x1234...abcd";
// Read the token name
const name = await publicClient.readContract({
address: CONTRACT,
abi,
functionName: "name"
});
console.log("Token name:", name); // "MyToken"Importing the ABI
The ABI (Application Binary Interface) is the contract's API definition — it lists every function, its inputs, outputs, and events. After compiling with Hardhat, the ABI is in artifacts/contracts/MyToken.sol/MyToken.json. Extract it into a separate file for your frontend.
// MyToken-abi.js — extracted from Hardhat artifacts
export const abi = [
{
name: "name",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [{ name: "", type: "string" }]
},
{
name: "balanceOf",
type: "function",
stateMutability: "view",
inputs: [{ name: "account", type: "address" }],
outputs: [{ name: "", type: "uint256" }]
},
// ... more functions and events
] as const;Formatting BigInt Results
Solidity uses uint256 for token amounts, which viem returns as JavaScript BigInt. A balance of 1 token with 18 decimals is 1000000000000000000n. Use viem's formatUnits to convert to a human-readable string.
import { formatUnits } from "viem";
const rawBalance = await publicClient.readContract({
address: CONTRACT,
abi,
functionName: "balanceOf",
args: [userAddress]
});
// rawBalance = 1500000000000000000n (1.5 tokens with 18 decimals)
const formatted = formatUnits(rawBalance, 18);
console.log("Balance:", formatted); // "1.5"Polling for Updates
Blockchain state changes with every block (~12 seconds on Ethereum). To keep your UI current, poll at regular intervals. Use setInterval to re-read contract data periodically.
async function refreshBalance() {
const balance = await publicClient.readContract({
address: CONTRACT,
abi,
functionName: "balanceOf",
args: [userAddress]
});
document.getElementById("balance").textContent =
formatUnits(balance, 18) + " MTK";
}
// Refresh every 12 seconds (one Ethereum block)
setInterval(refreshBalance, 12_000);
refreshBalance(); // also run immediatelyBatch Reads with Multicall
If you need to read multiple values (name, symbol, balance, totalSupply), sending one request per value is wasteful. multicall batches all reads into a single RPC call — faster and more efficient.
const results = await publicClient.multicall({
contracts: [
{ address: CONTRACT, abi, functionName: "name" },
{ address: CONTRACT, abi, functionName: "symbol" },
{ address: CONTRACT, abi, functionName: "totalSupply" },
{ address: CONTRACT, abi, functionName: "balanceOf", args: [userAddress] }
]
});
const [name, symbol, totalSupply, balance] = results.map(r => r.result);
console.log(name, symbol, formatUnits(totalSupply, 18), formatUnits(balance, 18));Writing to Contracts
Write operations modify on-chain state — transferring tokens, approving allowances, minting NFTs. Every write costs gas and requires the user to sign the transaction in MetaMask. Your frontend builds the transaction; MetaMask handles the rest.
walletClient.writeContract()
The writeContract method sends a state-changing transaction. When called, MetaMask opens a popup showing the transaction details. The user reviews the gas cost and clicks Confirm (or Reject). If confirmed, the method returns the transaction hash.
import { createWalletClient, custom } from "viem";
import { sepolia } from "viem/chains";
import { abi } from "./MyToken-abi.js";
const walletClient = createWalletClient({
chain: sepolia,
transport: custom(window.ethereum)
});
const CONTRACT = "0x1234...abcd";
// Transfer 10 tokens to Alice
const hash = await walletClient.writeContract({
address: CONTRACT,
abi,
functionName: "transfer",
args: ["0xAliceAddress", 10_000000000000000000n] // 10 tokens (18 decimals)
});
console.log("TX sent:", hash);Waiting for Confirmation
The transaction hash is returned immediately, but the transaction is not yet mined. Use publicClient.waitForTransactionReceipt() to wait until it is included in a block. This is where you show a loading spinner to the user.
// Send the transaction
const hash = await walletClient.writeContract({
address: CONTRACT,
abi,
functionName: "transfer",
args: [recipientAddress, amount]
});
// Wait for it to be mined
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status === "success") {
console.log("Transaction confirmed in block", receipt.blockNumber);
} else {
console.error("Transaction reverted!");
}Handling Reverts
Transactions can fail for many reasons: insufficient balance, access control, invalid arguments. When a contract call reverts, viem throws an error containing the revert reason. Always wrap write operations in try/catch and display a clear error message to the user.
import {
BaseError,
ContractFunctionRevertedError,
UserRejectedRequestError
} from "viem";
try {
const hash = await walletClient.writeContract({
address: CONTRACT,
abi,
functionName: "transfer",
args: [recipientAddress, amount]
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
showSuccess("Transfer complete!");
} catch (error) {
// viem wraps errors in a chain — walk() finds the underlying cause.
if (error instanceof BaseError) {
const rejected = error.walk(e => e instanceof UserRejectedRequestError);
if (rejected) return showError("Transaction rejected by user.");
const reverted = error.walk(e => e instanceof ContractFunctionRevertedError);
if (reverted instanceof ContractFunctionRevertedError) {
const name = reverted.data?.errorName ?? "";
if (name === "ERC20InsufficientBalance") {
return showError("Insufficient token balance.");
}
return showError(`Reverted: ${name || reverted.reason}`);
}
}
showError("Transaction failed: " + (error.shortMessage ?? error.message));
}Gas Estimation
Before sending a transaction, you can estimate the gas cost with publicClient.estimateContractGas(). This is useful for showing the user an approximate fee before they confirm. If the estimation fails, the transaction would revert — use it as a pre-flight check.
const gasEstimate = await publicClient.estimateContractGas({
address: CONTRACT,
abi,
functionName: "transfer",
args: [recipientAddress, amount],
account: userAddress
});
console.log("Estimated gas:", gasEstimate);
// Show to user: "This transaction will cost approximately X ETH"Parsing User Input
Users type amounts like "10.5" but Solidity expects raw integers with decimals baked in. Use viem's parseUnits to convert human-readable amounts to BigInt. Always validate input before calling parseUnits — non-numeric strings will throw.
import { parseUnits } from "viem";
const userInput = document.getElementById("amount").value; // "10.5"
const amount = parseUnits(userInput, 18); // 10500000000000000000n
await walletClient.writeContract({
address: CONTRACT,
abi,
functionName: "transfer",
args: [recipientAddress, amount]
});Events & Real-Time Updates
Smart contracts emit events to signal that something happened — a transfer, an approval, a mint. Your frontend can listen for these events to update the UI in real time, without polling. Events are the backbone of responsive DApps.
watchContractEvent: Live Subscriptions
The watchContractEvent method opens a subscription that fires a callback every time the contract emits a specific event. This requires a WebSocket transport for true real-time delivery (HTTP transport falls back to polling).
const unwatch = publicClient.watchContractEvent({
address: CONTRACT,
abi,
eventName: "Transfer",
onLogs: (logs) => {
for (const log of logs) {
console.log("Transfer detected:",
log.args.from, "->", log.args.to,
formatUnits(log.args.value, 18), "tokens"
);
// Update the UI
refreshBalance();
}
}
});
// To stop watching:
// unwatch();getLogs: Historical Data
Need to display past events — like a transfer history? Use getContractEvents to fetch logs from a range of blocks. You specify a fromBlock and toBlock, and viem returns all matching events.
const logs = await publicClient.getContractEvents({
address: CONTRACT,
abi,
eventName: "Transfer",
fromBlock: 5_000_000n,
toBlock: "latest"
});
for (const log of logs) {
console.log(
`Block ${log.blockNumber}: ${log.args.from} -> ${log.args.to}` +
`| ${formatUnits(log.args.value, 18)} tokens`
);
}Building a Real-Time Transaction Feed
Combine historical logs (to show past events on page load) with live subscriptions (to append new events as they happen). This gives users a complete, always-up-to-date activity feed.
async function initTransactionFeed() {
const feedEl = document.getElementById("tx-feed");
// 1. Load historical events
const pastLogs = await publicClient.getContractEvents({
address: CONTRACT, abi, eventName: "Transfer",
fromBlock: deployBlock, toBlock: "latest"
});
for (const log of pastLogs) {
appendToFeed(feedEl, log);
}
// 2. Subscribe to new events
publicClient.watchContractEvent({
address: CONTRACT, abi, eventName: "Transfer",
onLogs: (logs) => {
for (const log of logs) {
appendToFeed(feedEl, log);
}
}
});
}
function appendToFeed(container, log) {
const row = document.createElement("div");
row.textContent = `${log.args.from} -> ${log.args.to}: ` +
`${formatUnits(log.args.value, 18)} tokens`;
container.prepend(row);
}WebSocket vs Polling
Use WebSocket when real-time responsiveness matters (trading, auctions, live feeds). Use HTTP polling for simpler DApps where a few seconds of delay is acceptable.
Building the UI
Now we combine everything — wallet connection, reads, writes, events — into a complete, usable interface. No framework required. Vanilla HTML, CSS, and JavaScript is all you need.
The Connect Wallet Button
The Connect button is the entry point of every DApp. Before connection, show a prominent button. After connection, replace it with the user's address (truncated) and network indicator.
<button id="connectBtn">Connect Wallet</button>
<span id="walletInfo" style="display:none">
<span id="userAddress"></span>
<span id="networkBadge"></span>
</span>
<script>
document.getElementById("connectBtn").addEventListener("click", async () => {
const address = await connectWallet();
if (address) {
document.getElementById("connectBtn").style.display = "none";
document.getElementById("walletInfo").style.display = "inline";
document.getElementById("userAddress").textContent =
address.slice(0, 6) + "..." + address.slice(-4);
}
});
</script>Balance Display
Show the user's token balance prominently. Update it on page load, after every transaction, and on a polling interval. Use formatUnits for human-readable display.
<div class="balance-card">
<p class="label">Your Balance</p>
<p class="value" id="tokenBalance">--</p>
<p class="unit">MTK</p>
</div>Transaction Form
A simple form for sending tokens: recipient address input, amount input, and a Send button. Validate inputs before submitting — check for valid Ethereum addresses, positive amounts, and sufficient balance.
<form id="transferForm">
<label>Recipient Address</label>
<input type="text" id="recipient" placeholder="0x..." required>
<label>Amount (MTK)</label>
<input type="number" id="amount" step="0.01" min="0" required>
<button type="submit" id="sendBtn">Send Tokens</button>
<p id="txStatus"></p>
</form>
<script>
document.getElementById("transferForm").addEventListener("submit",
async (e) => {
e.preventDefault();
const recipient = document.getElementById("recipient").value;
const amount = parseUnits(
document.getElementById("amount").value, 18
);
await sendTransfer(recipient, amount);
}
);
</script>Status Indicators & Loading States
Blockchain transactions are slow (12+ seconds). Users need clear feedback at every stage: Pending (waiting for MetaMask signature), Submitted (transaction broadcast, waiting for mining), Confirmed (included in a block), or Failed (reverted or rejected).
async function sendTransfer(recipient, amount) {
const statusEl = document.getElementById("txStatus");
const sendBtn = document.getElementById("sendBtn");
try {
sendBtn.disabled = true;
statusEl.textContent = "Waiting for wallet signature...";
statusEl.className = "status-pending";
const hash = await walletClient.writeContract({
address: CONTRACT, abi,
functionName: "transfer",
args: [recipient, amount]
});
statusEl.textContent = "Transaction submitted. Waiting for confirmation...";
statusEl.className = "status-submitted";
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status === "success") {
statusEl.textContent = "Transfer confirmed!";
statusEl.className = "status-success";
refreshBalance();
} else {
statusEl.textContent = "Transaction reverted.";
statusEl.className = "status-error";
}
} catch (error) {
statusEl.textContent = error.shortMessage || "Transaction failed.";
statusEl.className = "status-error";
} finally {
sendBtn.disabled = false;
}
}Error Messages
Never show raw error objects to users. Map common errors to friendly messages: code 4001 means the user rejected the transaction, ERC20InsufficientBalance means they do not have enough tokens, and network errors mean the RPC is down. Always provide actionable guidance.
Responsive Design
Many DApp users are on mobile with MetaMask Mobile browser. Use a single-column layout on small screens, large tap targets for buttons, and readable font sizes. Test your DApp on both desktop and mobile viewports.
/* DApp responsive basics */
.dapp-container {
max-width: 600px;
margin: 0 auto;
padding: 1rem;
}
.balance-card {
background: #1a1a2e;
border-radius: 12px;
padding: 2rem;
text-align: center;
color: #e0e0e0;
}
.balance-card .value {
font-size: 2.5rem;
font-weight: bold;
color: #00d4ff;
}
.status-pending { color: #f0ad4e; }
.status-submitted { color: #5bc0de; }
.status-success { color: #5cb85c; }
.status-error { color: #d9534f; }
button {
min-height: 48px; /* mobile tap target */
font-size: 1rem;
cursor: pointer;
}
@media (max-width: 480px) {
.balance-card .value { font-size: 1.8rem; }
}Exercise & Self-Check
Exercises
- Wallet connection: Create an HTML page with a "Connect Wallet" button. When clicked, request MetaMask accounts, display the connected address (truncated), and listen for
accountsChangedevents. If the user switches accounts, update the displayed address. - Read contract data: Using a PublicClient, read the
name(),symbol(), andtotalSupply()of your deployed MyToken contract. Display all three values on the page. Usemulticallto fetch them in a single RPC call. - Transfer form: Build a form with recipient address and amount inputs. On submit, call
writeContractwith thetransferfunction. Show status messages (pending, submitted, confirmed, failed). Handle the user-rejection case (error code 4001). - Live event feed: Subscribe to
Transferevents usingwatchContractEvent. Display each new transfer in a list (from, to, amount). Also load historical transfers from the last 1000 blocks on page load usinggetContractEvents. - Full DApp: Combine exercises 1-4 into a complete single-page DApp. Add a balance display that auto-refreshes every 12 seconds. Add CSS styling with loading states and responsive design. Test on both desktop and MetaMask Mobile.
Self-Check Questions
- What is
window.ethereumand what standard does it implement? What happens if MetaMask is not installed? - Explain the difference between a PublicClient and a WalletClient in viem. When do you use each?
- Why do you need
formatUnitsandparseUnits? What would happen if you displayed raw BigInt values to the user? - Describe the full lifecycle of a write transaction from the user's perspective: what happens from clicking "Send" to seeing "Confirmed"?
- What is the difference between
watchContractEvent(live subscription) andgetContractEvents(historical query)? When would you use each?
Module Summary
You now know how to build a complete DApp frontend. You can connect wallets via MetaMask and window.ethereum, read contract data with PublicClient and multicall, write transactions with WalletClient and proper error handling, subscribe to events for real-time updates, and build a responsive UI with loading states and error messages. Your smart contracts are no longer invisible — they have a face.