Intermediate1.5 hours

Build an NFT Collection

Create a complete NFT collection with metadata, minting functionality, and IPFS integration using the ERC-721 standard.

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

FeatureERC-20ERC-721
FungibilityFungible (identical)Non-fungible (unique)
Token IDNot applicableUnique per token
MetadataOptionalEssential (tokenURI)
Use CasesCurrencies, governanceArt, collectibles, deeds

2. Project Setup

Initialize a new Hardhat project with the required dependencies:

Terminal
mkdir nft-collection && cd nft-collection
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts
npx hardhat init

3. NFT Metadata & IPFS

NFT metadata follows a standard JSON format. Here's the structure:

metadata/1.json
{
  "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