1. Introduction to ERC-721
ERC-721 is the standard for non-fungible tokens (NFTs) on Ethereum. Unlike ERC-20 tokens where each token is identical, each ERC-721 token is unique and can represent ownership of digital or physical assets.
Key Differences from ERC-20
| Feature | ERC-20 | ERC-721 |
|---|---|---|
| Fungibility | Fungible (identical) | Non-fungible (unique) |
| Token ID | Not applicable | Unique per token |
| Metadata | Optional | Essential (tokenURI) |
| Use Cases | Currencies, governance | Art, collectibles, deeds |
2. Project Setup
Initialize a new Hardhat project with the required dependencies:
mkdir nft-collection && cd nft-collection
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
npx hardhat init3. NFT Metadata & IPFS
NFT metadata follows a standard JSON format. Here's the structure:
{
"name": "Cool NFT #1",
"description": "A unique piece from the Cool Collection",
"image": "ipfs://QmXxx.../1.png",
"attributes": [
{
"trait_type": "Background",
"value": "Blue"
},
{
"trait_type": "Rarity",
"value": "Legendary"
},
{
"display_type": "number",
"trait_type": "Generation",
"value": 1
}
]
}IPFS Storage Options
Use Pinata or NFT.Storage for reliable, free IPFS pinning services.
4. Smart Contract
Create contracts/CoolNFT.sol:
// 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";
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 mintingEnabled = false;
mapping(address => uint256) public mintedPerWallet;
event Minted(address indexed to, uint256 indexed tokenId);
constructor(string memory baseURI)
ERC721("Cool NFT", "COOL")
Ownable(msg.sender)
{
_baseTokenURI = baseURI;
}
function mint(uint256 quantity) external payable {
require(mintingEnabled, "Minting not enabled");
require(quantity > 0, "Quantity must be > 0");
require(_nextTokenId + quantity <= MAX_SUPPLY, "Exceeds max supply");
require(mintedPerWallet[msg.sender] + quantity <= MAX_PER_WALLET, "Exceeds wallet limit");
require(msg.value >= MINT_PRICE * quantity, "Insufficient payment");
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = _nextTokenId++;
_safeMint(msg.sender, tokenId);
emit Minted(msg.sender, tokenId);
}
mintedPerWallet[msg.sender] += quantity;
}
function ownerMint(address to, uint256 quantity) external onlyOwner {
require(_nextTokenId + quantity <= MAX_SUPPLY, "Exceeds max supply");
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
emit Minted(to, tokenId);
}
}
function setMintingEnabled(bool enabled) external onlyOwner {
mintingEnabled = enabled;
}
function setBaseURI(string memory baseURI) external onlyOwner {
_baseTokenURI = baseURI;
}
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
function _baseURI() internal view override returns (string memory) {
return _baseTokenURI;
}
function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage)
returns (string memory)
{
return string(abi.encodePacked(_baseTokenURI, tokenId.toString(), ".json"));
}
// Required overrides
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);
}
}5. Minting Functionality
The contract includes several minting features:
- Public Minting: Users can mint up to 5 NFTs per wallet at 0.05 ETH each.
- Owner Minting: Owner can mint for free (for giveaways/team allocation).
- Minting Toggle: Owner can enable/disable public minting.
- Supply Cap: Maximum 10,000 NFTs can ever be minted.
Gas Optimization
For large collections, consider using ERC721A which optimizes batch minting to reduce gas costs by up to 90%.
6. Frontend Integration
Example React component for minting:
import { useState } from 'react';
import { ethers } from 'ethers';
import CoolNFT from './CoolNFT.json';
const CONTRACT_ADDRESS = "0x...";
function MintButton() {
const [quantity, setQuantity] = useState(1);
const [loading, setLoading] = useState(false);
async function mint() {
if (!window.ethereum) {
alert('Please install MetaMask');
return;
}
setLoading(true);
try {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const contract = new ethers.Contract(
CONTRACT_ADDRESS,
CoolNFT.abi,
signer
);
const price = ethers.parseEther("0.05");
const tx = await contract.mint(quantity, {
value: price * BigInt(quantity)
});
await tx.wait();
alert('Successfully minted!');
} catch (error) {
console.error(error);
alert('Minting failed');
}
setLoading(false);
}
return (
<div>
<input
type="number"
min="1"
max="5"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
/>
<button onClick={mint} disabled={loading}>
{loading ? 'Minting...' : `Mint ${quantity} NFT(s)`}
</button>
</div>
);
}7. Deployment
Deployment checklist before going live:
- Upload all metadata and images to IPFS
- Test thoroughly on testnet (Sepolia)
- Audit contract or use established patterns
- Verify contract on Etherscan
- Set up OpenSea collection metadata