NFT minting dApps get a bad reputation for being unnecessarily complicated, but at their core they're just a React frontend talking to a smart contract. If you already know React and have touched a REST API, you have most of the mental model you need — the blockchain is just a different kind of backend.
In this tutorial, we'll build a minimal but complete NFT minting dApp: connect a wallet, display mint status, and mint an NFT by calling a smart contract from React using Ethers.js. We'll keep the smart contract simple so the focus stays on the frontend integration, which is where most developers get stuck.
What We're Building
- A React app with a "Connect Wallet" button
- A "Mint NFT" button that calls a smart contract function
- Live feedback: pending transaction, success, or error states
- A read-only display of how many NFTs have been minted so far
We'll assume you already have a simple ERC-721 contract deployed to a testnet (like Sepolia). If not, OpenZeppelin's Contracts Wizard can generate one in a few minutes — that part isn't the focus here.
Prerequisites
- Node.js installed
- A React app scaffolded (Vite recommended:
npm create vite@latest my-nft-dapp -- --template react) - MetaMask installed in your browser
- An ERC-721 contract deployed to a testnet, with its ABI and contract address handy
- Some testnet ETH from a faucet (e.g., Sepolia faucet) for gas
Step 1: Install Ethers.js
npm install ethers
We're using Ethers v6, which has a cleaner API than v5 and is the current standard.
Step 2: Set Up the Contract Interface
Create a file to hold your contract details so they're not scattered across components.
// src/contract.js
export const CONTRACT_ADDRESS = "0xYourContractAddressHere";
export const CONTRACT_ABI = [
"function mint() public payable",
"function totalSupply() public view returns (uint256)",
"function balanceOf(address owner) public view returns (uint256)"
];
You only need to include the functions you're actually calling — Ethers doesn't need the full ABI, just the fragments relevant to your interactions. This is a small but useful trick that keeps your frontend code readable.
Step 3: Connect the Wallet
This is the part every dApp needs, and it's simpler than most tutorials make it look.
// src/hooks/useWallet.js
import { useState } from "react";
import { BrowserProvider } from "ethers";
export function useWallet() {
const [account, setAccount] = useState(null);
const [provider, setProvider] = useState(null);
const [error, setError] = useState(null);
async function connectWallet() {
if (!window.ethereum) {
setError("MetaMask not detected. Please install it.");
return;
}
try {
const browserProvider = new BrowserProvider(window.ethereum);
const accounts = await browserProvider.send("eth_requestAccounts", []);
setProvider(browserProvider);
setAccount(accounts[0]);
setError(null);
} catch (err) {
setError("Wallet connection failed.");
console.error(err);
}
}
return { account, provider, error, connectWallet };
}
A few things worth noting here:
-
BrowserProviderwrapswindow.ethereum, which MetaMask injects into the page. -
eth_requestAccountstriggers the MetaMask popup asking the user to connect. - We're storing errors in state rather than just logging them, because dApp users need visible feedback — a silent failure here is a common source of "why isn't this working" bug reports.
Step 4: Build the Minting Hook
Now the part that actually talks to your smart contract.
// src/hooks/useMint.js
import { useState } from "react";
import { Contract, parseEther } from "ethers";
import { CONTRACT_ADDRESS, CONTRACT_ABI } from "../contract";
export function useMint(provider) {
const [status, setStatus] = useState("idle"); // idle | pending | success | error
const [txHash, setTxHash] = useState(null);
const [errorMessage, setErrorMessage] = useState(null);
async function mintNFT() {
if (!provider) {
setErrorMessage("Connect your wallet first.");
setStatus("error");
return;
}
try {
setStatus("pending");
setErrorMessage(null);
const signer = await provider.getSigner();
const contract = new Contract(CONTRACT_ADDRESS, CONTRACT_ABI, signer);
// Adjust the value below to match your contract's mint price
const tx = await contract.mint({ value: parseEther("0.01") });
setTxHash(tx.hash);
await tx.wait(); // wait for confirmation
setStatus("success");
} catch (err) {
console.error(err);
setErrorMessage(err.reason || "Transaction failed.");
setStatus("error");
}
}
return { mintNFT, status, txHash, errorMessage };
}
A couple of details that trip people up:
-
provider.getSigner()returns the connected wallet as a signer, which is required to send transactions (as opposed to just reading data). -
tx.hashis available immediately after the transaction is submitted, before it's confirmed — this lets you show the user a "pending" state with a link to a block explorer right away. -
tx.wait()pauses execution until the transaction is mined. This is where most of the wait time happens, and it's important to reflect that in the UI rather than leaving the button looking clickable. -
err.reasonoften contains the actual revert reason from the contract (e.g., "Sale not active" or "Insufficient payment"), which is far more useful to show the user than a generic error.
Step 5: Read the Total Supply
Not every contract interaction needs a wallet or gas — reading on-chain data is free and doesn't require a signer.
// src/hooks/useTotalSupply.js
import { useState, useEffect } from "react";
import { Contract, JsonRpcProvider } from "ethers";
import { CONTRACT_ADDRESS, CONTRACT_ABI } from "../contract";
const RPC_URL = "https://sepolia.infura.io/v3/YOUR_INFURA_KEY";
export function useTotalSupply(refreshTrigger) {
const [totalSupply, setTotalSupply] = useState(null);
useEffect(() => {
async function fetchSupply() {
try {
const readProvider = new JsonRpcProvider(RPC_URL);
const contract = new Contract(CONTRACT_ADDRESS, CONTRACT_ABI, readProvider);
const supply = await contract.totalSupply();
setTotalSupply(supply.toString());
} catch (err) {
console.error("Failed to fetch total supply", err);
}
}
fetchSupply();
}, [refreshTrigger]);
return totalSupply;
}
Using a separate JsonRpcProvider for reads (rather than requiring a connected wallet) means visitors can see mint progress even before connecting MetaMask — which is good UX and a small detail a lot of tutorials skip.
Step 6: Put It Together in the UI
// src/App.jsx
import { useWallet } from "./hooks/useWallet";
import { useMint } from "./hooks/useMint";
import { useTotalSupply } from "./hooks/useTotalSupply";
function App() {
const { account, provider, error: walletError, connectWallet } = useWallet();
const { mintNFT, status, txHash, errorMessage } = useMint(provider);
const totalSupply = useTotalSupply(status);
return (
<div style={{ maxWidth: 480, margin: "60px auto", textAlign: "center" }}>
<h1>Mint Your NFT</h1>
<p>Total minted: {totalSupply ?? "loading..."}</p>
{!account ? (
<button onClick={connectWallet}>Connect Wallet</button>
) : (
<>
<p>Connected: {account.slice(0, 6)}...{account.slice(-4)}</p>
<button onClick={mintNFT} disabled={status === "pending"}>
{status === "pending" ? "Minting..." : "Mint NFT"}
</button>
</>
)}
{walletError && <p style={{ color: "red" }}>{walletError}</p>}
{status === "success" && (
<p style={{ color: "green" }}>
Minted! View on{" "}
<a href={`https://sepolia.etherscan.io/tx/${txHash}`} target="_blank" rel="noreferrer">
Etherscan
</a>
</p>
)}
{status === "error" && <p style={{ color: "red" }}>{errorMessage}</p>}
</div>
);
}
export default App;
Notice that totalSupply refetches whenever status changes — that's the refreshTrigger dependency doing its job, so the mint count updates automatically right after a successful mint without needing a manual refresh.
Common Pitfalls to Watch For
A few issues come up repeatedly when developers build their first minting dApp:
-
Forgetting to handle chain mismatches. If a user's wallet is connected to Mainnet but your contract is on Sepolia, transactions will fail confusingly. Check
provider.getNetwork()and prompt the user to switch chains if needed. - Hardcoding gas limits. Let Ethers estimate gas automatically rather than setting a fixed value — contract logic changes can make hardcoded limits fail silently.
-
Not handling rejected transactions. If a user clicks "Reject" in MetaMask,
mint()throws an error that should be caught gracefully, not treated as a network failure. -
Assuming
tx.hashmeans success. A transaction hash only means the transaction was submitted, not confirmed. Always wait fortx.wait()before showing a success state.
Where to Go From Here
This is intentionally minimal — enough to understand the actual data flow between React and a smart contract without extra abstraction getting in the way. From here, natural next steps include adding wallet-switching support, showing minted NFT metadata and images after a successful mint, handling ERC-721 batch minting, and swapping MetaMask-only support for a wallet connector library like RainbowKit or Wagmi if you want broader wallet compatibility.
The pattern underneath all of it stays the same, though: a provider to read the chain, a signer to write to it, and a contract instance that ties your ABI to both. Once that clicks, most Web3 frontend work is just applying this same loop to different contract functions.
Top comments (0)