What Are NFTs and ERC-721?
NFTs (Non-Fungible Tokens) are unique digital assets represented on a blockchain. Unlike ERC-20 tokens where every token is identical, each ERC-721 token has a unique tokenId and can represent ownership of a distinct item — digital art, music, game items, domain names, event tickets, real estate deeds, or any unique asset. The ERC-721 standard defines the interface that all NFT contracts must implement.
ERC-721 Core Functions
- balanceOf(owner): How many NFTs an address owns
- ownerOf(tokenId): Who owns a specific NFT
- safeTransferFrom(from, to, tokenId): Transfer an NFT safely (checks if receiver can handle NFTs)
- approve(to, tokenId): Approve another address to transfer a specific NFT
- setApprovalForAll(operator, approved): Approve an operator for all your NFTs
- tokenURI(tokenId): Returns the metadata URI for an NFT (ERC721Metadata extension)
Building an NFT Collection
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
/// @title CoolNFT — A complete NFT collection contract
contract CoolNFT is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable {
using Strings for uint256;
uint256 private _nextTokenId;
uint256 public constant MAX_SUPPLY = 10000;
uint256 public constant MINT_PRICE = 0.05 ether;
uint256 public constant MAX_PER_WALLET = 5;
string private _baseTokenURI;
bool public mintingActive = false;
mapping(address => uint256) public mintCount;
event Minted(address indexed to, uint256 indexed tokenId);
error MintingNotActive();
error MaxSupplyReached();
error MaxPerWalletReached();
error InsufficientPayment();
constructor(string memory baseURI)
ERC721("Cool NFT", "COOL")
Ownable(msg.sender)
{
_baseTokenURI = baseURI;
}
function mint(uint256 quantity) external payable {
if (!mintingActive) revert MintingNotActive();
if (_nextTokenId + quantity > MAX_SUPPLY) revert MaxSupplyReached();
if (mintCount[msg.sender] + quantity > MAX_PER_WALLET) revert MaxPerWalletReached();
if (msg.value < MINT_PRICE * quantity) revert InsufficientPayment();
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = _nextTokenId++;
_safeMint(msg.sender, tokenId);
emit Minted(msg.sender, tokenId);
}
mintCount[msg.sender] += quantity;
}
function toggleMinting() external onlyOwner {
mintingActive = !mintingActive;
}
function setBaseURI(string memory baseURI) external onlyOwner {
_baseTokenURI = baseURI;
}
function withdraw() external onlyOwner {
(bool success, ) = owner().call{value: address(this).balance}("");
require(success, "Withdraw failed");
}
// --- Required overrides for multiple inheritance ---
function _baseURI() internal view override returns (string memory) {
return _baseTokenURI;
}
function tokenURI(uint256 tokenId)
public view override(ERC721, ERC721URIStorage) returns (string memory)
{
return super.tokenURI(tokenId);
}
function _update(address to, uint256 tokenId, address auth)
internal override(ERC721, ERC721Enumerable) returns (address)
{
return super._update(to, tokenId, auth);
}
function _increaseBalance(address account, uint128 value)
internal override(ERC721, ERC721Enumerable)
{
super._increaseBalance(account, value);
}
function supportsInterface(bytes4 interfaceId)
public view override(ERC721, ERC721Enumerable, ERC721URIStorage) returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
NFT Metadata Standard
NFT metadata is typically stored as a JSON file, either on IPFS (decentralized) or a traditional server. The tokenURI function returns the URL to this JSON, which follows a standard format that marketplaces like OpenSea understand.
// Example metadata JSON (stored on IPFS)
// ipfs://QmHash.../1.json
{
"name": "Cool NFT #1",
"description": "A unique digital collectible from the Cool NFT collection.",
"image": "ipfs://QmImageHash.../1.png",
"animation_url": "ipfs://QmAnimHash.../1.mp4", // Optional video/animation
"external_url": "https://coolnft.com/token/1",
"attributes": [
{ "trait_type": "Background", "value": "Sunset" },
{ "trait_type": "Body", "value": "Robot" },
{ "trait_type": "Eyes", "value": "Laser" },
{ "trait_type": "Rarity Score", "display_type": "number", "value": 85 },
{ "trait_type": "Generation", "display_type": "number", "value": 1 }
]
}
Minting from a Frontend
'use client';
import { useWriteContract, useWaitForTransactionReceipt, useReadContract } from 'wagmi';
import { parseEther } from 'viem';
import { useState } from 'react';
const nftConfig = {
address: '0xContractAddress...' as const,
abi: [
{
name: 'mint',
type: 'function',
stateMutability: 'payable',
inputs: [{ name: 'quantity', type: 'uint256' }],
outputs: [],
},
{
name: 'totalSupply',
type: 'function',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'uint256' }],
},
{
name: 'MINT_PRICE',
type: 'function',
stateMutability: 'view',
inputs: [],
outputs: [{ name: '', type: 'uint256' }],
},
] as const,
};
export function MintButton() {
const [quantity, setQuantity] = useState(1);
const { data: totalSupply } = useReadContract({
...nftConfig,
functionName: 'totalSupply',
});
const { writeContract, data: hash, isPending } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash });
function handleMint() {
writeContract({
...nftConfig,
functionName: 'mint',
args: [BigInt(quantity)],
value: parseEther((0.05 * quantity).toString()),
});
}
return (
<div className="space-y-4">
<p>Minted: {totalSupply?.toString()} / 10,000</p>
<div className="flex items-center gap-2">
<button onClick={() => setQuantity(Math.max(1, quantity - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(Math.min(5, quantity + 1))}>+</button>
</div>
<p>Total: {(0.05 * quantity).toFixed(2)} ETH</p>
<button onClick={handleMint} disabled={isPending || isConfirming}>
{isPending ? 'Confirm in wallet...' : isConfirming ? 'Minting...' : `Mint ${quantity} NFT(s)`}
</button>
{isSuccess && <p>Successfully minted!</p>}
</div>
);
}
NFT Best Practices
- Store media on IPFS: Use decentralized storage for images and metadata so they persist even if your server goes down
- Use ERC721Enumerable carefully: It adds significant gas overhead. Only include it if you need on-chain enumeration.
- Implement royalties (EIP-2981): Define creator royalties that marketplaces can honor on secondary sales
- Consider reveal mechanics: For generative collections, use a delayed reveal to prevent metadata sniping during mint
- Test thoroughly: NFT contracts handle real value. Audit your mint logic, access controls, and withdrawal functions.