Web3 & Blockchain/Ethereum/Create NFT

Create an NFT (ERC-721)

ERC-721 is the standard for non-fungible tokens — each token has a unique ID and can have its own metadata. Every PFP collection, on-chain game item, and digital artwork lives here.


ERC-721 vs ERC-1155

ERC-721ERC-1155
Token typeEach token is uniqueCan be fungible OR non-fungible
TransferOne at a timeBatch transfer in one tx
GasHigher per tokenLower for bulk ops
Use case1/1 art, PFP collectionsGames, mixed-supply drops

Use ERC-721 for pure NFT collections. Use ERC-1155 for games or editions.


Basic ERC-721 with OpenZeppelin

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MyNFT is ERC721URIStorage, Ownable {
    uint256 private _tokenIdCounter;

    constructor() ERC721("My NFT", "MNFT") Ownable(msg.sender) {}

    function mint(address to, string memory tokenURI) external onlyOwner returns (uint256) {
        uint256 tokenId = _tokenIdCounter++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, tokenURI);
        return tokenId;
    }
}

_safeMint checks if the recipient can handle ERC-721 tokens (important when minting to contracts).


Collection with Max Supply and Public Mint

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract PhantomCollection is ERC721, Ownable {
    uint256 public constant MAX_SUPPLY = 10_000;
    uint256 public constant MINT_PRICE = 0.05 ether;
    uint256 public totalMinted;
    string private _baseTokenURI;

    constructor(string memory baseURI) ERC721("Phantom Collection", "PHANTOM") Ownable(msg.sender) {
        _baseTokenURI = baseURI;
    }

    function mint(uint256 quantity) external payable {
        require(totalMinted + quantity <= MAX_SUPPLY, "Exceeds max supply");
        require(msg.value >= MINT_PRICE * quantity, "Insufficient payment");

        for (uint256 i = 0; i < quantity; i++) {
            _safeMint(msg.sender, totalMinted++);
        }
    }

    function _baseURI() internal view override returns (string memory) {
        return _baseTokenURI;
    }

    // tokenURI returns: baseURI + tokenId
    // e.g., "ipfs://QmXxx.../1"

    function setBaseURI(string memory newURI) external onlyOwner {
        _baseTokenURI = newURI;
    }

    function withdraw() external onlyOwner {
        (bool ok, ) = owner().call{ value: address(this).balance }("");
        require(ok, "Withdraw failed");
    }
}

Metadata Structure

NFT metadata is typically stored as JSON on IPFS. The tokenURI points to this file.

{
  "name": "Phantom #001",
  "description": "A dark phantom from the void.",
  "image": "ipfs://QmImageHash.../001.png",
  "external_url": "https://wayphantom.dev",
  "attributes": [
    { "trait_type": "Background", "value": "Dark" },
    { "trait_type": "Eyes", "value": "Glowing Red" },
    { "trait_type": "Rarity", "value": "Legendary" },
    { "trait_type": "Power", "display_type": "number", "value": 95 }
  ]
}

Upload to IPFS via Pinata

import PinataSDK from "@pinata/sdk";
import fs from "fs";

const pinata = new PinataSDK({ pinataApiKey: process.env.PINATA_KEY!, pinataSecretApiKey: process.env.PINATA_SECRET! });

// 1. Upload image
const imageStream = fs.createReadStream("./images/001.png");
const imageResult = await pinata.pinFileToIPFS(imageStream, { pinataMetadata: { name: "Phantom #001" } });
const imageURI = `ipfs://${imageResult.IpfsHash}`;

// 2. Upload metadata JSON
const metadata = {
  name: "Phantom #001",
  description: "A dark phantom from the void.",
  image: imageURI,
  attributes: [{ trait_type: "Background", value: "Dark" }],
};
const metaResult = await pinata.pinJSONToIPFS(metadata);
const tokenURI = `ipfs://${metaResult.IpfsHash}`;

Deploy and Mint

import { ethers } from "hardhat";

async function main() {
  const [deployer] = await ethers.getSigners();

  const NFT = await ethers.getContractFactory("PhantomCollection");
  const nft = await NFT.deploy("ipfs://QmBaseMetadataHash/");
  await nft.waitForDeployment();

  const address = await nft.getAddress();
  console.log("NFT deployed to:", address);

  // Mint 1 token to deployer (owner mint — no payment needed for owner)
  // For public mint, send value
  const tx = await nft.mint(1, { value: ethers.parseEther("0.05") });
  await tx.wait();
  console.log("Minted. Owner:", await nft.ownerOf(0));
}

main().catch(console.error);

Read NFT Data from Frontend

import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const nft = new ethers.Contract(NFT_ADDRESS, NFT_ABI, provider);

// Token owner
const owner = await nft.ownerOf(0);

// Token metadata URI
const uri = await nft.tokenURI(0);
// → "ipfs://QmXxx.../0"

// Fetch metadata from IPFS
const res = await fetch(uri.replace("ipfs://", "https://ipfs.io/ipfs/"));
const metadata = await res.json();
console.log(metadata.name);    // "Phantom #001"
console.log(metadata.image);   // "ipfs://QmImage..."

Lazy Minting (Signature-Based)

Instead of minting on-chain upfront, issue signed vouchers and let users mint themselves.

function mintWithSignature(
    uint256 tokenId,
    string memory uri,
    bytes memory signature
) external payable {
    require(msg.value >= MINT_PRICE, "Underpaid");

    // Recreate the signed message hash
    bytes32 hash = keccak256(abi.encodePacked(tokenId, uri, msg.sender));
    bytes32 ethHash = MessageHashUtils.toEthSignedMessageHash(hash);

    // Verify it was signed by the owner
    require(ECDSA.recover(ethHash, signature) == owner(), "Invalid signature");

    _safeMint(msg.sender, tokenId);
    _setTokenURI(tokenId, uri);
}

ERC-721A (Gas-Optimized Batch Mint)

For large collections, ERC-721A by Azuki cuts gas significantly when minting multiple tokens at once.

npm install erc721a
import "erc721a/contracts/ERC721A.sol";

contract PhantomA is ERC721A, Ownable {
    constructor() ERC721A("Phantom", "PHM") Ownable(msg.sender) {}

    function mint(uint256 quantity) external payable {
        require(totalSupply() + quantity <= 10_000, "Exceeds max");
        require(msg.value >= 0.05 ether * quantity, "Underpaid");
        _mint(msg.sender, quantity); // batch mint, ~same gas as single ERC-721 mint
    }
}

Royalties (ERC-2981)

import "@openzeppelin/contracts/token/common/ERC2981.sol";

contract RoyaltyNFT is ERC721, ERC2981, Ownable {
    constructor() ERC721("Royalty NFT", "RNFT") Ownable(msg.sender) {
        // 5% royalty to deployer on all secondary sales
        _setDefaultRoyalty(msg.sender, 500); // 500 = 5% in basis points
    }

    function supportsInterface(bytes4 interfaceId)
        public view override(ERC721, ERC2981) returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Related: Create ERC-20 Token | NFT Marketplace DApp | Solana NFT

Last updated · September 2026