TechLead
Lesson 8 of 20
5 min read
Web3 & Blockchain

ERC-721 NFT Standard

Build and deploy non-fungible tokens (NFTs) using the ERC-721 standard with metadata, minting, and marketplace integration

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.

Continue Learning