Education › Blockchain & Web3 › Guided project

Mint an NFT collection

Build a real NFT collection: an ERC-721 contract with a public mint function, artwork and metadata stored on IPFS, deployed to a testnet, and minted from your own wallet — so you understand exactly what an NFT is and where its image actually lives.

Intermediate about 6 hours 7 phases · 25 steps 0 / 25 done
What you will have at the end

An ERC-721 collection deployed to Sepolia, with metadata on IPFS, a working mint function, and at least one token you minted showing up in your wallet and on a testnet marketplace or explorer.

Before you start
  • The ERC-20 token project (or equivalent Hardhat comfort) — you'll reuse the same setup, testing, and deployment loop
  • The Blockchain lessons through Tokens and standards and Web3: storage and identity — you'll use ERC-721 and IPFS content addressing
  • A wallet on Sepolia with test ETH, and a free IPFS pinning account (such as Pinata or web3.storage / NFT.Storage)
Tools you will install
  • Hardhat + OpenZeppelin ERC721 — the audited non-fungible-token base you inherit, plus the develop-test-deploy loop ↗
  • IPFS pinning (Pinata / NFT.Storage) — store the artwork and metadata off-chain, referenced by CID so it's tamper-evident ↗
  • MetaMask + a Sepolia faucet — the account you deploy and mint from, and free test ETH for gas ↗
  • A testnet explorer / marketplace — see your minted tokens and their metadata render ↗

Tick each step as you finish it — your progress is saved in this browser only (back up or restore on the hub). Every code block has a copy button. If something goes wrong, the troubleshooting section at the end covers the usual suspects.

Phase 1

Understand what you're building

Get the mental model straight before coding, so the IPFS and tokenURI steps make sense rather than feeling like magic.

  1. Write down, in your own words, the split you're about to build: the contract stores who owns each tokenId on-chain, while each token's image and metadata live off-chain on IPFS, referenced by a URL the contract returns from tokenURI.
    The NFT is the on-chain ownership record, not the image. Keeping this straight is what makes the rest of the project coherent.
  2. Sketch the metadata JSON shape each token will use — the standard fields marketplaces read: name, description, and image (an IPFS URL). You'll create one of these per token.
    json
    {
      "name": "My Collection #1",
      "description": "The first token in my test collection.",
      "image": "ipfs://REPLACE_WITH_IMAGE_CID"
    }
  3. Note why the image goes off-chain: storing even a small image directly on Ethereum would cost far more in gas than the picture is worth, because every node stores it forever. This cost is exactly why the CID-reference pattern exists.
    If someone claims an NFT's art is 'on the blockchain', it almost never is — the chain holds ownership and a reference; the bytes live off-chain. Knowing this protects you from a common misconception.
Phase 2

Set up the project

A Hardhat project with OpenZeppelin ready, reusing the workflow from the token project.

  1. Confirm Node.js is 18 or newer, since Hardhat requires a current runtime.
    bash
    node --version
    Check: The version printed is v18 or higher.
  2. Create and initialise a Hardhat project and install OpenZeppelin, exactly as in the token project.
    bash
    mkdir my-nft && cd my-nft
    npm init -y
    npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
    npx hardhat init
    npm install @openzeppelin/contracts dotenv
    Check: The project scaffolds and OpenZeppelin is installed.
  3. Remove the sample Lock contract and test so you start clean, then confirm the project compiles.
    bash
    rm -f contracts/Lock.sol test/Lock.js
    npx hardhat compile
    Check: Compilation runs with no errors.
Phase 3

Store the art and metadata on IPFS

Put the actual files off-chain and get the CIDs your contract will point to.

  1. Prepare a small image for token #1 (any image is fine for a test). Upload it to your IPFS pinning service and copy the returned CID — this is the content address of your image.
    Check: You have an image CID, and opening ipfs://that-cid through a gateway shows your image.
  2. Create the metadata JSON for token #1, setting its image field to ipfs://your-image-CID. Upload that JSON to IPFS too and copy its CID — this metadata CID is what the contract's tokenURI will return.
    Two uploads per token: the image, then a metadata JSON that references the image's CID. The contract only ever stores/returns the metadata reference.
  3. Verify the whole chain resolves: fetch the metadata CID through a gateway, confirm it's your JSON, and that its image field points to an image that loads. If this resolves, marketplaces will render your NFT.
    Check: The metadata JSON loads from its CID and its image link displays the picture.
  4. In your pinning service, confirm both files are actually pinned (kept), not merely cached. Pinning is what keeps content-addressed data available, since IPFS guarantees integrity but not permanence.
    A token whose metadata later 'disappears' is almost always an unpinned file. Make sure the service is pinning, and understand you're responsible for keeping the data hosted.
Phase 4

Write the NFT contract

An ERC-721 that anyone can mint from, assigning each new token the next id and its metadata URI.

  1. Create contracts/MyNFT.sol inheriting OpenZeppelin's ERC721URIStorage (an ERC-721 that can store a per-token URI). Add a counter and a public mint function that mints the next id to the caller and sets its tokenURI.
    solidity
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.20;
    
    import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
    
    contract MyNFT is ERC721URIStorage {
        uint256 private _nextId;
    
        constructor() ERC721("My Collection", "MYC") {}
    
        function mint(string memory metadataURI) public returns (uint256) {
            uint256 tokenId = _nextId++;
            _safeMint(msg.sender, tokenId);        // caller becomes the owner
            _setTokenURI(tokenId, metadataURI);    // ipfs://metadata-CID
            return tokenId;
        }
    }
  2. Compile the contract and fix any import or version issues.
    bash
    npx hardhat compile
    Check: MyNFT compiles and an artifact is produced.
  3. Decide your collection name and symbol in the constructor (here "My Collection" / "MYC") — these are permanent identity for the whole collection, shown by wallets and marketplaces, so pick them deliberately before deploying.
    Unlike a token's per-transfer data, the name and symbol are set once at deployment and can't be changed later, so choose them as carefully as you'd name a product.
  4. Read the ERC721URIStorage source you inherited and confirm ownerOf, balanceOf, transferFrom, safeTransferFrom and tokenURI all come from it — you added only the mint logic and the id counter.
    As with the token, the standard behaviour is inherited and audited; you wrote just the part that's specific to your collection.
Phase 5

Test it

Deploy and mint in tests so you trust the contract before spending gas.

  1. Create test/MyNFT.js. Deploy the contract, mint a token with a sample URI, and check the caller owns token 0 and its tokenURI matches what you set.
    javascript
    const { expect } = require("chai");
    const { ethers } = require("hardhat");
    
    describe("MyNFT", function () {
      it("mints a token to the caller with its URI", async function () {
        const [owner] = await ethers.getSigners();
        const NFT = await ethers.getContractFactory("MyNFT");
        const nft = await NFT.deploy();
        const uri = "ipfs://sample-metadata-cid";
        await nft.mint(uri);
        expect(await nft.ownerOf(0)).to.equal(owner.address);
        expect(await nft.tokenURI(0)).to.equal(uri);
      });
    });
  2. Add a test that mints two tokens and checks the ids increment (0 then 1) and the owner's balance is 2, then run the suite.
    bash
    npx hardhat test
    Check: All tests pass, confirming ids increment and ownership is tracked.
  3. Add a failure-case test: calling ownerOf (or tokenURI) for a token id that was never minted should revert. Asserting this confirms the contract doesn't invent owners for nonexistent tokens.
    Testing that queries on nonexistent tokens revert is a small but important check — it proves the contract's view of ownership is exactly the tokens actually minted.
Phase 6

Deploy and mint on a testnet

Put the collection on Sepolia and mint your first real token pointing at your IPFS metadata.

  1. Set up your .env with a Sepolia RPC URL and a test-only private key, add the sepolia network to hardhat.config.js, and add .env to .gitignore — the same secure setup as the token project.
    bash
    printf 'SEPOLIA_RPC_URL=your-rpc-url\nPRIVATE_KEY=your-test-key\n' > .env
    echo '.env' >> .gitignore
    Use a throwaway key with only test ETH. Never commit .env.
  2. Write scripts/deploy.js to deploy MyNFT and print its address, then deploy to Sepolia and save the address.
    bash
    npx hardhat run scripts/deploy.js --network sepolia
    Check: The contract address prints and appears on sepolia.etherscan.io.
  3. Mint token #1 by calling mint with your real metadata URI (ipfs://your-metadata-CID) — either from a small script or the Hardhat console attached to the deployed address.
    javascript
    // scripts/mint.js
    const { ethers } = require("hardhat");
    async function main() {
      const nft = await ethers.getContractAt("MyNFT", "YOUR_CONTRACT_ADDRESS");
      const tx = await nft.mint("ipfs://YOUR_METADATA_CID");
      await tx.wait();
      console.log("minted token, tx:", tx.hash);
    }
    main().catch((e) => { console.error(e); process.exit(1); });
    Check: The mint transaction confirms and you own a new token in the collection.
Phase 7

See it render and share it

Confirm your NFT shows up with its art, and turn the project into a portfolio piece.

  1. Import the collection into MetaMask's NFTs tab using your contract address and token id, and confirm the token appears under your account. Your wallet is simply reading ownerOf and tokenURI from the contract.
    Check: The NFT shows in MetaMask's NFTs tab for the account that minted it.
  2. Find your token on a testnet explorer or a testnet-enabled marketplace by its contract address and token id, and confirm the image and name render from your IPFS metadata.
    Check: Your NFT displays its image and name, pulled from the IPFS metadata.
  3. Transfer the token to a second address of yours (or a friend's) and watch ownership change on-chain, confirming the ERC-721 transfer works.
    Check: ownerOf the token returns the new address after the transfer confirms.
  4. Verify the contract source on Etherscan (as in the token project) so the code behind your collection is public and readable.
    bash
    npx hardhat verify --network sepolia YOUR_CONTRACT_ADDRESS
    Check: Etherscan shows the verified source for your NFT contract.
  5. Write a short README with the contract address, the IPFS CIDs, and how to mint, so the collection is a shareable portfolio artifact — a real NFT collection you built end to end.
    You now understand NFTs from the inside: an on-chain ownership record plus off-chain, content-addressed metadata — not a mysterious 'image on the blockchain'.
Help

Troubleshooting

The NFT shows up but with no image or a broken image.
The metadata or image CID isn't resolving. Confirm you uploaded BOTH the image and a metadata JSON, that the metadata's image field is ipfs://the-image-CID, and that the token's tokenURI points at the metadata CID. Test each CID through a public gateway before blaming the contract.
Minting reverts or the token id isn't what you expected.
Check you're calling mint with a string URI argument and that the counter starts where you think (this contract starts at 0). If a transaction reverts, read the revert reason; a common cause is a typo in the contract address you attached to.
A marketplace doesn't show the NFT even though ownerOf is correct.
Some marketplaces index slowly or only support certain testnets, and they read the metadata format strictly. Confirm your metadata JSON has name, description and image fields, that it resolves from its CID, and give the indexer time; the explorer is the source of truth for ownership.
Deployment or mint fails with 'insufficient funds'.
Your deployer account needs Sepolia ETH for gas. Request more from a faucet to the exact deployer address, wait for it to arrive, and retry — deploying and minting are both gas-costing transactions.
IPFS content stops loading later.
IPFS guarantees integrity, not permanence — data must stay pinned. Ensure your pinning service is actually pinning the files (not just caching them), or they can disappear. For a durable collection, use a service that keeps content pinned.
Next

Where to go from here

Did a step fail or feel unclear? Tell me which one →