Implementing NFT Minting via Web Interface
NFT minting is a blockchain transaction that creates a new token in the contract. A web interface for minting is UI on top of a smart contract: wallet connection, condition checks (whitelist, public sale, limits), transaction sending, status tracking, final screen with token.
Implementation complexity depends on contract mechanics: free mint, whitelist via merkle tree, ERC-2981 royalties, price curve, max per wallet.
Analyzing Contract Before Development
First step — study contract ABI. Typical mint contract (ERC-721A — more popular than pure ERC-721 for cheap batch minting):
// Typical mint contract functions
function mint(uint256 quantity) external payable;
function whitelistMint(uint256 quantity, bytes32[] calldata proof) external payable;
function totalSupply() external view returns (uint256);
function maxSupply() external view returns (uint256);
function mintPrice() external view returns (uint256);
function maxPerWallet() external view returns (uint256);
function saleState() external view returns (uint8); // 0=paused, 1=whitelist, 2=public
function numberMinted(address owner) external view returns (uint256);
Reading Contract State
// lib/mintContract.ts
import { createPublicClient, http, parseAbi } from 'viem';
import { mainnet } from 'viem/chains';
const MINT_ABI = parseAbi([
'function totalSupply() view returns (uint256)',
'function maxSupply() view returns (uint256)',
'function mintPrice() view returns (uint256)',
'function maxPerWallet() view returns (uint256)',
'function saleState() view returns (uint8)',
'function numberMinted(address) view returns (uint256)',
'function mint(uint256 quantity) payable',
'function whitelistMint(uint256 quantity, bytes32[] proof) payable',
]);
export async function getMintState(
contractAddress: `0x${string}`,
walletAddress: `0x${string}` | null,
) {
const client = createPublicClient({ chain: mainnet, transport: http() });
const calls = [
{ address: contractAddress, abi: MINT_ABI, functionName: 'totalSupply' },
{ address: contractAddress, abi: MINT_ABI, functionName: 'maxSupply' },
{ address: contractAddress, abi: MINT_ABI, functionName: 'mintPrice' },
{ address: contractAddress, abi: MINT_ABI, functionName: 'maxPerWallet' },
{ address: contractAddress, abi: MINT_ABI, functionName: 'saleState' },
...(walletAddress ? [{
address: contractAddress,
abi: MINT_ABI,
functionName: 'numberMinted',
args: [walletAddress],
}] : []),
] as const;
const results = await client.multicall({ contracts: calls });
return {
totalSupply: results[0].result as bigint,
maxSupply: results[1].result as bigint,
mintPrice: results[2].result as bigint,
maxPerWallet: results[3].result as bigint,
saleState: results[4].result as number,
numberMinted: walletAddress ? (results[5].result as bigint) : 0n,
};
}
Whitelist via Merkle Tree
Most modern mints use merkle proof instead of storing all whitelist addresses in contract. Proof is generated on frontend by user address:
// lib/merkle.ts
import { MerkleTree } from 'merkletreejs';
import { keccak256, encodePacked } from 'viem';
// allowlist.json — array of addresses from CMS or API
import allowlist from '@/data/allowlist.json';
function hashLeaf(address: string): `0x${string}` {
return keccak256(encodePacked(['address'], [address as `0x${string}`]));
}
const leaves = allowlist.map(hashLeaf);
const tree = new MerkleTree(leaves, keccak256, { sortPairs: true });
export function getMerkleProof(address: string): `0x${string}`[] {
const leaf = hashLeaf(address);
return tree.getHexProof(leaf) as `0x${string}`[];
}
export function isWhitelisted(address: string): boolean {
const leaf = hashLeaf(address);
return tree.verify(tree.getHexProof(leaf), leaf, tree.getRoot());
}
Minting Transaction
// hooks/useMint.ts
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
export function useMint(contractAddress: `0x${string}`) {
const { writeContract, data: txHash, isPending, error } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash: txHash,
});
const mint = async (quantity: number, price: bigint) => {
writeContract({
address: contractAddress,
abi: MINT_ABI,
functionName: 'mint',
args: [BigInt(quantity)],
value: price * BigInt(quantity),
});
};
const whitelistMint = async (
quantity: number,
price: bigint,
proof: `0x${string}`[],
) => {
writeContract({
address: contractAddress,
abi: MINT_ABI,
functionName: 'whitelistMint',
args: [BigInt(quantity), proof],
value: price * BigInt(quantity),
});
};
return { mint, whitelistMint, txHash, isPending, isConfirming, isSuccess, error };
}
UI Minting Component
// components/MintWidget.tsx
import { useState } from 'react';
import { formatEther } from 'viem';
import { useAccount } from 'wagmi';
import { useMint } from '@/hooks/useMint';
import { getMintState } from '@/lib/mintContract';
import { getMerkleProof, isWhitelisted } from '@/lib/merkle';
import { useQuery } from '@tanstack/react-query';
const CONTRACT = process.env.NEXT_PUBLIC_CONTRACT_ADDRESS as `0x${string}`;
export function MintWidget() {
const { address, isConnected } = useAccount();
const [quantity, setQuantity] = useState(1);
const { mint, whitelistMint, isPending, isConfirming, isSuccess, txHash } = useMint(CONTRACT);
const { data: state } = useQuery({
queryKey: ['mintState', address],
queryFn: () => getMintState(CONTRACT, address ?? null),
refetchInterval: 10_000,
});
if (!state) return <MintSkeleton />;
const sold = Number(state.totalSupply);
const total = Number(state.maxSupply);
const priceEth = formatEther(state.mintPrice);
const remaining = total - sold;
const canMint = Number(state.maxPerWallet) - Number(state.numberMinted);
const handleMint = async () => {
if (!address) return;
if (state.saleState === 1) {
// Whitelist sale
if (!isWhitelisted(address)) return;
const proof = getMerkleProof(address);
await whitelistMint(quantity, state.mintPrice, proof);
} else {
await mint(quantity, state.mintPrice);
}
};
return (
<div className="space-y-6 rounded-2xl border border-white/10 bg-neutral-900 p-6">
{/* Progress */}
<div>
<div className="mb-2 flex justify-between text-sm">
<span>{sold} / {total} minted</span>
<span>{remaining} remaining</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-neutral-800">
<div
className="h-full rounded-full bg-blue-500 transition-all"
style={{ width: `${(sold / total) * 100}%` }}
/>
</div>
</div>
{/* Quantity */}
<QuantitySelector
value={quantity}
onChange={setQuantity}
max={Math.min(canMint, remaining, 10)}
/>
{/* Total */}
<div className="flex justify-between text-sm">
<span className="text-neutral-400">Cost</span>
<span>{(parseFloat(priceEth) * quantity).toFixed(4)} ETH</span>
</div>
{/* Button */}
<MintButton
state={state.saleState}
address={address}
isConnected={isConnected}
isPending={isPending || isConfirming}
isSuccess={isSuccess}
canMint={canMint > 0}
onClick={handleMint}
/>
{/* Transaction Status */}
{txHash && (
<a
href={`https://etherscan.io/tx/${txHash}`}
target="_blank"
rel="noopener noreferrer"
className="block text-center text-xs text-blue-400 hover:underline"
>
View transaction →
</a>
)}
</div>
);
}
Error Handling
Minting fails for many reasons: insufficient ETH, exceeded wallet limit, sale not active, invalid proof. Errors from viem contain ABI-decoded contract message:
import { ContractFunctionRevertedError, UserRejectedRequestError } from 'viem';
function parseMintError(error: Error): string {
if (error instanceof UserRejectedRequestError) {
return 'Transaction rejected in wallet';
}
if (error instanceof ContractFunctionRevertedError) {
const reason = error.data?.errorName ?? error.message;
const messages: Record<string, string> = {
'ExceedsMaxPerWallet': 'Token limit per wallet exceeded',
'SaleNotActive': 'Sale not started yet',
'InvalidMerkleProof': 'Your address is not whitelisted',
'InsufficientFunds': 'Insufficient ETH',
'MaxSupplyReached': 'All tokens minted',
};
return messages[reason] ?? `Contract error: ${reason}`;
}
return 'Unknown error';
}
Timeline: minting interface with public mint, progress bar, and basic error handling — 2–3 days. Full implementation with whitelist via merkle tree, multi-phase sale, and final token screen — 4–6 days.







