← Course home Module 12 / 19 Blockchain — Part 2

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.

~75 min read Module 12 ⟠ Part 2

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

Layer
Technology
Role
Frontend
HTML / CSS / JS
User interface — forms, buttons, status displays. Runs entirely in the browser.
Blockchain Client
viem
Sends read/write requests to the blockchain. Encodes/decodes ABI data.
Wallet
MetaMask
Holds private keys. Signs transactions. User approves every write operation.
Smart Contract
Solidity (on-chain)
Business logic. Executes on the EVM. Immutable once deployed.

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.

Wallet Detection JavaScript
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.

Connect Wallet JavaScript
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.

Wallet Events JavaScript
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.

Switch Chain JavaScript
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?

Feature
viem
ethers.js v6
TypeScript
First-class — types inferred from ABI.
Supported, but types are less granular.
Bundle size
Tree-shakeable. Import only what you use.
Larger baseline even with tree-shaking.
Performance
Optimized encoding/decoding, batch support.
Solid, but heavier abstractions.
API design
Explicit clients (Public vs Wallet).
Single Provider/Signer model.

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.

PublicClient TypeScript
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.

WalletClient TypeScript
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.

Transports TypeScript
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.

readContract TypeScript
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 TypeScript
// 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.

formatUnits TypeScript
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.

Polling JavaScript
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 immediately

Batch 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.

Multicall TypeScript
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.

writeContract TypeScript
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.

Wait for Receipt TypeScript
// 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.

Error Handling TypeScript
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.

Gas Estimation TypeScript
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.

parseUnits TypeScript
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).

watchContractEvent TypeScript
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.

getContractEvents TypeScript
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.

Real-Time Feed JavaScript
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

Approach
Pros
Cons
WebSocket
True real-time. Events arrive instantly. Lower bandwidth.
Requires WSS endpoint. Connection can drop — need reconnection logic.
HTTP Polling
Simple. Works with any RPC endpoint. No connection management.
Delayed (depends on poll interval). Higher bandwidth. More RPC calls.
📡

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.

Connect Button JavaScript
<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.

Balance Card HTML
<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.

Transfer Form JavaScript
<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).

Status Management JavaScript
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 CSS CSS
/* 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

  1. Wallet connection: Create an HTML page with a "Connect Wallet" button. When clicked, request MetaMask accounts, display the connected address (truncated), and listen for accountsChanged events. If the user switches accounts, update the displayed address.
  2. Read contract data: Using a PublicClient, read the name(), symbol(), and totalSupply() of your deployed MyToken contract. Display all three values on the page. Use multicall to fetch them in a single RPC call.
  3. Transfer form: Build a form with recipient address and amount inputs. On submit, call writeContract with the transfer function. Show status messages (pending, submitted, confirmed, failed). Handle the user-rejection case (error code 4001).
  4. Live event feed: Subscribe to Transfer events using watchContractEvent. Display each new transfer in a list (from, to, amount). Also load historical transfers from the last 1000 blocks on page load using getContractEvents.
  5. 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

  1. What is window.ethereum and what standard does it implement? What happens if MetaMask is not installed?
  2. Explain the difference between a PublicClient and a WalletClient in viem. When do you use each?
  3. Why do you need formatUnits and parseUnits? What would happen if you displayed raw BigInt values to the user?
  4. Describe the full lifecycle of a write transaction from the user's perspective: what happens from clicking "Send" to seeing "Confirmed"?
  5. What is the difference between watchContractEvent (live subscription) and getContractEvents (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.

Next module

Your DApp frontend works. But right now, deployment is manual and error-prone. Module 13 covers deployment pipelines — deploying to testnets and mainnet, verifying contracts on Etherscan, and automating the entire process.

Module 13: Smart Contract Security →
Course Project

Ready to apply everything you've learned? The end-of-course project combines smart contracts, AI, and Node.js into a real decentralised academic assistant.

View Project Brief →