Mint Your First NFT Collection with ERC-721

Want to understand how Bored Apes, Pudgy Penguins, or Azuki actually work? Build your own NFT collection.
In the next 90-120 minutes, you'll deploy a complete NFT collection to Ethereum's test network. A real collection that appears in MetaMask, can be listed on NFT marketplaces like Rarible, and follows the same standard securing billions in NFT value. You'll learn how NFT projects generate revenue, why metadata lives off-chain, and what makes each token unique.
Note: If you've completed the ERC-20 guide, you'll recognize the Hardhat setup process. The smart contract logic is where things get interesting. NFTs work fundamentally differently from fungible tokens.
What You'll Learn
This isn't just a coding exercise. You'll understand the architecture behind every successful NFT project:
- Why NFTs aren't just "JPEGs on the blockchain" - The contract stores ownership. The metadata lives on IPFS. Understanding this separation is critical to how NFTs work.
- How NFT projects generate millions in revenue - Payment-gated minting is the business model. You'll implement the mechanism projects use during public sales.
- Where IPFS fits in the Web3 stack - Storing images on Ethereum would cost thousands per NFT. IPFS provides decentralized storage at a fraction of the cost.
- Why marketplaces like OpenSea automatically list your NFTs - Standards matter. Following ERC-721 means instant compatibility with the entire NFT ecosystem.
What You Need
| Requirement | Why It Matters | Get It Here |
|---|---|---|
| Node.js & npm | Runs the Hardhat development environment and manages code dependencies | nodejs.org |
| MetaMask wallet | Holds test funds and signs the deployment transaction that creates your contract | metamask.io |
| Sepolia test ETH | Pays for gas fees (~$0 in real value, but required for test network) | Google Cloud Faucet |
| Alchemy RPC URL | Your connection point to Ethereum. Like an API key for blockchain access. | alchemy.com (free tier) |
| Pinata account | Hosts your NFT images and metadata on IPFS: the decentralized storage layer | pinata.cloud (free tier) |
Understanding What You're Building
Before writing code, let's understand what makes NFTs different from ERC-20 tokens.
With ERC-20 tokens, every token is identical. One USDC is exactly the same as another USDC. They're fungible, interchangeable. NFTs flip this concept. Each token is unique, identified by a specific token ID, with its own metadata describing what it represents.
Your NFT collection will work like successful projects such as Bored Ape Yacht Club or Pudgy Penguins. A fixed collection size (maxSupply), public minting for a price, and metadata pointing to IPFS storage. The contract controls who can mint and tracks which addresses own which specific tokens.
The critical difference from your stablecoin: NFTs don't have balances in the traditional sense. An address doesn't hold "5.2 NFTs". It owns token #1, token #47, and token #203. Each is distinct. Each has unique metadata. Each can have different rarity or attributes.
Now let's build this.
Project Setup With Hardhat
We'll create a new Hardhat project for your NFT collection. If you've completed the ERC-20 guide, these steps will be familiar, but we're starting fresh with a new project directory.
1. Initialize Your NFT Project
Run Hardhat's interactive initializer:
npx hardhat --init
When prompted, make these selections:
- Version:
Hardhat 3 Beta (recommended for new projects) - Project location:
my-nft(or your preferred name) - Project type:
A TypeScript Hardhat project using Node Test Runner and Viem - Dependencies: Confirm with
Yto install
Navigate to your new project:
cd my-nft
2. Install OpenZeppelin Contracts
Install OpenZeppelin's audited contract library:
npm install --save-dev @openzeppelin/contracts
3. Create Your NFT Contract File
touch contracts/MyNFT.sol
Smart Contract Development
We'll build your NFT contract step by step, inheriting from three OpenZeppelin contracts: ERC721 (core NFT functionality), ERC721URIStorage (per-token metadata), and Ownable (administrative control).
1. Initial Imports and Contract Structure
Start with the foundation. Open contracts/MyNFT.sol and add these imports that give you everything needed for a production-ready NFT contract:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyNFT is ERC721, ERC721URIStorage, Ownable {
constructor() {}
}
What each import provides:
ERC721: The base NFT standard. Provides ownership tracking, transfers, approvals, and events. This is what makes your contract compatible with every NFT marketplace and wallet.ERC721URIStorage: Adds storage for individual token metadata URIs. Without this, all tokens would share the same base URI pattern. With it, token #1 can point to one IPFS hash while token #2 points to another. Essential for collections with unique artwork.Ownable: ProvidesonlyOwneraccess control, just like your stablecoin used. The deployer becomes the owner with special privileges: free minting for airdrops, price adjustments, and fund withdrawal.
2. State Variables and Constructor
Define what makes your collection unique: its name, symbol, supply limit, and minting price. Add this to your contracts/MyNFT.sol:
contract MyNFT is ERC721, ERC721URIStorage, Ownable {
uint256 private _nextTokenId;
uint256 public maxSupply;
uint256 public mintPrice;
/**
* @dev Constructor to initialize the NFT collection
* @param name_ The name of the NFT collection
* @param symbol_ The symbol of the NFT collection
* @param maxSupply_ Maximum number of NFTs that can be minted
* @param mintPrice_ Price to mint each NFT (in wei)
*/
constructor(
string memory name_,
string memory symbol_,
uint256 maxSupply_,
uint256 mintPrice_
) ERC721(name_, symbol_) Ownable(msg.sender) {
maxSupply = maxSupply_;
mintPrice = mintPrice_;
}
}
State variables:
_nextTokenId: Tracks which token ID to mint next. Starts at 0, increments with each mint.maxSupply: Hard cap on collection size. This limit is permanent. Even the owner can't exceed it.mintPrice: How much ETH users must pay to mint (stored in wei). This is where NFT projects generate revenue.
3. Public Mint Function
The core of your NFT contract: it allows anyone to mint by paying the price. Add this function to contracts/MyNFT.sol:
/**
* @dev Mint a new NFT with metadata URI
* @param to Address to mint the NFT to
* @param uri Metadata URI for the NFT
*/
function mint(address to, string memory uri) public payable {
require(_nextTokenId < maxSupply, "Max supply reached");
require(msg.value >= mintPrice, "Insufficient payment");
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
Key concepts:
payablemodifier: This function accepts ETH with the transaction.msg.value: The amount of ETH sent, measured in wei._safeMint(to, tokenId): OpenZeppelin's safe minting function that prevents minting to contracts that can't handle NFTs._setTokenURI(tokenId, uri): Stores a unique metadata URI for this specific token.
The payment flow: User calls mint() with ETH → Contract verifies payment → Contract mints token → Contract keeps the ETH → Owner can withdraw later.
4. Owner Mint Function
Free minting reserved for the collection owner: used for team allocation, giveaways, and promotional campaigns. Add this to contracts/MyNFT.sol:
function ownerMint(address to, string memory uri) public onlyOwner {
require(_nextTokenId < maxSupply, "Max supply reached");
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
This is identical to public mint() minus the payment requirement. The onlyOwner modifier ensures that only the contract deployer can call this function. It's useful for team allocation, giveaways, and airdrops. Still constrained by maxSupply.
5. Helper Functions
Utility functions for collection management: supply tracking, price adjustments, and revenue withdrawal. Add these to contracts/MyNFT.sol:
/**
* @dev Get the total number of NFTs minted so far
*/
function totalSupply() public view returns (uint256) {
return _nextTokenId;
}
/**
* @dev Update the mint price (only owner)
* @param newPrice New price in wei
*/
function setMintPrice(uint256 newPrice) public onlyOwner {
mintPrice = newPrice;
}
/**
* @dev Withdraw collected funds (only owner)
*/
function withdraw() public onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "No funds to withdraw");
payable(owner()).transfer(balance);
}
Function purposes:
totalSupply(): Returns how many NFTs have been minted.setMintPrice(): Allows price adjustments after deployment (owner only).withdraw(): Transfers accumulated ETH from mints to the owner.
6. Required Override Functions
These functions exist because of Solidity's multiple inheritance rules. Both ERC721 and ERC721URIStorage implement the same functions, so we must explicitly tell Solidity which implementation to use. Add these final functions to contracts/MyNFT.sol:
// The following functions are overrides required by Solidity
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721URIStorage)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
This is Solidity boilerplate required when inheriting from both ERC721 and ERC721URIStorage. Copy it exactly.
You're done. Your complete NFT contract in ~100 lines. It handles public minting with payment, owner privileges, metadata storage, and fund management. The same architecture securing billions in NFT value.
Writing Tests
Your NFT contract handles payments, manages ownership of unique tokens, and controls access to admin functions. Unlike your stablecoin where minting changed a balance, NFT minting involves payment verification, token ID assignment, and metadata storage. More complexity means more potential failure points
Consider what goes wrong without testing: Someone mints without paying enough. The owner accidentally sets maxSupply to 0. A user burns a token they don't own. The withdraw function fails silently. These aren't theoretical. Similar bugs have cost NFT projects millions in lost revenue or reputation damage.
We'll test using the same dual approach as your stablecoin:
Solidity tests run fast on the EVM, perfect for testing individual functions in isolation. Can users mint without paying? Does the supply cap work? Unit tests prove each function behaves correctly.
TypeScript tests simulate real user flows from start to finish. Deploy the contract, mint some NFTs, transfer one, and withdraw funds, all in sequence. These catch integration issues that unit tests miss.
Solidity Unit Tests
We'll use Foundry-style tests to verify each function works correctly, including all edge cases and failure modes.
1. Setup
Create the test file:
touch contracts/MyNFT.t.sol
Add the test contract with setup function:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {MyNFT} from "./MyNFT.sol";
import {Test} from "forge-std/Test.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract MyNFTTest is Test {
MyNFT nft;
string constant NAME = "My NFT";
string constant SYMBOL = "MNFT";
uint256 constant MAX_SUPPLY = 100;
uint256 constant MINT_PRICE = 0.01 ether;
string constant SAMPLE_URI = "ipfs://QmSampleHash123/1.json";
address owner = address(this);
address user1 = makeAddr("user1");
address user2 = makeAddr("user2");
function setUp() public {
// Deploy the NFT contract
nft = new MyNFT(NAME, SYMBOL, MAX_SUPPLY, MINT_PRICE);
// Fund test users with ETH
vm.deal(user1, 10 ether);
vm.deal(user2, 10 ether);
}
}
Setup notes:
vm.deal(user1, 10 ether): Foundry cheatcode that gives addresses ETH for minting.SAMPLE_URI: Placeholder IPFS hash for testing.
2. Test Deployment State
Verify the contract initializes correctly:
/**
* @notice Tests if the contract is deployed with the correct initial state
*/
function test_ConstructorState() public view {
assertEq(nft.name(), NAME, "Incorrect token name");
assertEq(nft.symbol(), SYMBOL, "Incorrect token symbol");
assertEq(nft.maxSupply(), MAX_SUPPLY, "Incorrect max supply");
assertEq(nft.mintPrice(), MINT_PRICE, "Incorrect mint price");
assertEq(nft.owner(), owner, "Incorrect owner");
assertEq(nft.totalSupply(), 0, "Total supply should start at 0");
}
NFT collections start empty. Users mint tokens after deployment.
3. Test Public Minting
Test the payment-based minting flow:
/**
* @notice Tests if a user can successfully mint by paying the mint price
*/
function test_Mint_Success() public {
vm.prank(user1);
nft.mint{value: MINT_PRICE}(user1, SAMPLE_URI);
assertEq(nft.totalSupply(), 1, "Total supply should be 1");
assertEq(nft.ownerOf(0), user1, "User1 should own token 0");
assertEq(nft.tokenURI(0), SAMPLE_URI, "Token URI should match");
assertEq(address(nft).balance, MINT_PRICE, "Contract should hold the payment");
}
/**
* @notice Tests that minting fails with insufficient payment
*/
function test_Mint_InsufficientPayment() public {
vm.prank(user1);
vm.expectRevert("Insufficient payment");
nft.mint{value: 0.001 ether}(user1, SAMPLE_URI); // Too low
}
/**
* @notice Tests that minting fails when max supply is reached
*/
function test_Mint_MaxSupplyReached() public {
// Deploy with max supply of 1
MyNFT smallNFT = new MyNFT(NAME, SYMBOL, 1, MINT_PRICE);
// Mint first token
vm.prank(user1);
smallNFT.mint{value: MINT_PRICE}(user1, SAMPLE_URI);
// Try to mint second token
vm.prank(user2);
vm.expectRevert("Max supply reached");
smallNFT.mint{value: MINT_PRICE}(user2, SAMPLE_URI);
}
Key patterns:
{value: MINT_PRICE}: Sends ETH with the function call.address(nft).balance: Checks the contract's accumulated ETH.ownerOf(tokenId): Returns the owner of a specific token.tokenURI(tokenId): Returns the metadata URI for a token.
4. Test Owner Minting
Verify owner can mint for free and non-owners cannot:
/**
* @notice Tests that the owner can mint without payment
*/
function test_OwnerMint_Success() public {
nft.ownerMint(user1, SAMPLE_URI);
assertEq(nft.totalSupply(), 1, "Total supply should be 1");
assertEq(nft.ownerOf(0), user1, "User1 should own token 0");
assertEq(address(nft).balance, 0, "No payment should be held");
}
/**
* @notice Tests that non-owners cannot call ownerMint
*/
function test_OwnerMint_NotOwner() public {
vm.prank(user1);
vm.expectRevert();
nft.ownerMint(user1, SAMPLE_URI);
}
/**
* @notice Tests owner can mint multiple tokens for airdrops
*/
function test_OwnerMint_Batch() public {
address[] memory recipients = new address;
recipients[0] = user1;
recipients[1] = user2;
recipients[2] = makeAddr("user3");
for (uint256 i = 0; i < recipients.length; i++) {
nft.ownerMint(recipients[i], string(abi.encodePacked(SAMPLE_URI, vm.toString(i))));
}
assertEq(nft.totalSupply(), 3, "Should have minted 3 tokens");
assertEq(nft.balanceOf(user1), 1, "User1 should own 1 NFT");
}
5. Test Price Management
Verify only the owner can adjust pricing:
/**
* @notice Tests that the owner can update the mint price
*/
function test_SetMintPrice_Success() public {
uint256 newPrice = 0.05 ether;
nft.setMintPrice(newPrice);
assertEq(nft.mintPrice(), newPrice, "Mint price should be updated");
// Verify new price is enforced
vm.prank(user1);
nft.mint{value: newPrice}(user1, SAMPLE_URI);
assertEq(nft.totalSupply(), 1, "Should mint with new price");
}
/**
* @notice Tests that non-owners cannot update the mint price
*/
function test_SetMintPrice_NotOwner() public {
vm.prank(user1);
vm.expectRevert();
nft.setMintPrice(0.05 ether);
}
6. Test Withdrawals
Verify the owner can collect sales revenue:
/**
* @notice Tests that the owner can withdraw collected funds
*/
function test_Withdraw_Success() public {
// Users mint some NFTs
vm.prank(user1);
nft.mint{value: MINT_PRICE}(user1, SAMPLE_URI);
vm.prank(user2);
nft.mint{value: MINT_PRICE}(user2, SAMPLE_URI);
uint256 contractBalance = address(nft).balance;
assertEq(contractBalance, MINT_PRICE * 2, "Contract should hold 2x mint price");
uint256 ownerBalanceBefore = address(owner).balance;
nft.withdraw();
uint256 ownerBalanceAfter = address(owner).balance;
assertEq(address(nft).balance, 0, "Contract balance should be 0");
assertEq(ownerBalanceAfter - ownerBalanceBefore, contractBalance, "Owner should receive the funds");
}
/**
* @notice Tests that withdraw fails when no funds are available
*/
function test_Withdraw_NoFunds() public {
vm.expectRevert("No funds to withdraw");
nft.withdraw();
}
/**
* @notice Tests that non-owners cannot withdraw funds
*/
function test_Withdraw_NotOwner() public {
vm.prank(user1);
nft.mint{value: MINT_PRICE}(user1, SAMPLE_URI);
vm.prank(user2);
vm.expectRevert();
nft.withdraw();
}
7. Test ERC721 Standard
Verify token transfers work correctly:
/**
* @notice Tests that NFTs can be transferred between addresses
*/
function test_Transfer() public {
// Mint token to user1
vm.prank(user1);
nft.mint{value: MINT_PRICE}(user1, SAMPLE_URI);
// User1 transfers to user2
vm.prank(user1);
nft.transferFrom(user1, user2, 0);
assertEq(nft.ownerOf(0), user2, "User2 should now own the token");
assertEq(nft.balanceOf(user1), 0, "User1 should have 0 NFTs");
assertEq(nft.balanceOf(user2), 1, "User2 should have 1 NFT");
}
/**
* @notice Tests ERC721 interface support
*/
function test_SupportsInterface() public view {
// ERC721 interface ID: 0x80ac58cd
assertTrue(nft.supportsInterface(0x80ac58cd), "Should support ERC721");
}
These verify ERC-721 compliance. Marketplaces rely on these functions working correctly for trading NFTs.
8. Run Solidity Tests
Execute your test suite:
npx hardhat test solidity contracts/MyNFT.t.sol
You should see all tests passing, confirming your contract logic is solid.
TypeScript End-to-End Tests
Now we'll test a realistic user journey: deploy, mint multiple NFTs, adjust price, and withdraw funds.
1. Setup
Create the TypeScript test file:
touch test/MyNFT.ts
Add the test structure:
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { network } from "hardhat";
import { parseEther } from "viem";
describe("MyNFT", async function () {
const { viem } = await network.connect();
const publicClient = await viem.getPublicClient();
const NAME = "My NFT";
const SYMBOL = "MNFT";
const MAX_SUPPLY = 100n;
const MINT_PRICE = parseEther("0.01");
const SAMPLE_URI = "ipfs://QmSampleHash123/1.json";
// Tests will go here
});
2. Implement Complete NFT Lifecycle Test
This single test covers the entire NFT project lifecycle:
it("should handle a complete NFT project lifecycle", async function () {
// 1. SETUP: Get test accounts
const [owner, buyer1, buyer2] = await viem.getWalletClients();
console.log("\n=== NFT Project Lifecycle Test ===\n");
// 2. DEPLOYMENT
console.log("1. Deploying NFT collection...");
const nft = await viem.deployContract("MyNFT", [
NAME,
SYMBOL,
MAX_SUPPLY,
MINT_PRICE
], {
client: { wallet: owner }
});
console.log(\` ✓ Collection deployed at: ${nft.address}\`);
console.log(\` ✓ Name: ${await nft.read.name()}\`);
console.log(\` ✓ Symbol: ${await nft.read.symbol()}\`);
console.log(\` ✓ Max Supply: ${await nft.read.maxSupply()}\`);
console.log(\` ✓ Mint Price: ${await nft.read.mintPrice()} wei\n\`);
// 3. VERIFY INITIAL STATE
assert.equal(await nft.read.totalSupply(), 0n, "Should start with 0 supply");
assert.equal(
(await nft.read.owner()).toLowerCase(),
owner.account.address.toLowerCase(),
"Owner should be deployer"
);
// 4. OWNER AIRDROP (Free minting)
console.log("2. Owner minting airdrop tokens (free)...");
const airdropTx = await nft.write.ownerMint([buyer1.account.address, SAMPLE_URI]);
await publicClient.waitForTransactionReceipt({ hash: airdropTx });
console.log(\` ✓ Airdropped token #0 to ${buyer1.account.address}\`);
assert.equal(await nft.read.totalSupply(), 1n, "Supply should be 1");
assert.equal(await nft.read.balanceOf([buyer1.account.address]), 1n, "Buyer1 should have 1 NFT");
// 5. PUBLIC MINT (With payment)
console.log("\n3. Public mint (with payment)...");
const mintTx = await nft.write.mint(
[buyer1.account.address, "ipfs://QmHash2/2.json"],
{
value: MINT_PRICE,
account: buyer1.account
}
);
await publicClient.waitForTransactionReceipt({ hash: mintTx });
console.log(\` ✓ Buyer1 minted token #1 for ${parseEther("0.01")} wei\`);
assert.equal(await nft.read.totalSupply(), 2n, "Supply should be 2");
// Verify contract received payment
const contractBalance = await publicClient.getBalance({ address: nft.address });
assert.equal(contractBalance, MINT_PRICE, "Contract should hold the mint price");
console.log(\` ✓ Contract balance: ${contractBalance} wei\n\`);
// 6. MINT WITH INSUFFICIENT PAYMENT (Should fail)
console.log("4. Testing insufficient payment (should fail)...");
try {
await nft.write.mint(
[buyer2.account.address, "ipfs://QmHash3/3.json"],
{
value: parseEther("0.001"), // Too low
account: buyer2.account
}
);
assert.fail("Should have reverted with insufficient payment");
} catch (error) {
console.log(\` ✓ Correctly rejected insufficient payment\n\`);
}
// 7. UPDATE MINT PRICE
console.log("5. Owner updating mint price...");
const newPrice = parseEther("0.02");
const updatePriceTx = await nft.write.setMintPrice([newPrice]);
await publicClient.waitForTransactionReceipt({ hash: updatePriceTx });
assert.equal(await nft.read.mintPrice(), newPrice, "Price should be updated");
console.log(\` ✓ Mint price updated to ${newPrice} wei\n\`);
// 8. MINT AT NEW PRICE
console.log("6. Minting at new price...");
const mintTx2 = await nft.write.mint(
[buyer2.account.address, "ipfs://QmHash3/3.json"],
{
value: newPrice,
account: buyer2.account
}
);
await publicClient.waitForTransactionReceipt({ hash: mintTx2 });
console.log(\` ✓ Buyer2 minted token #2 at new price\`);
assert.equal(await nft.read.totalSupply(), 3n, "Supply should be 3");
// 9. TRANSFER NFT
console.log("\n7. Testing NFT transfer...");
const transferTx = await nft.write.transferFrom(
[buyer1.account.address, buyer2.account.address, 0n],
{ account: buyer1.account }
);
await publicClient.waitForTransactionReceipt({ hash: transferTx });
assert.equal(
(await nft.read.ownerOf([0n])).toLowerCase(),
buyer2.account.address.toLowerCase(),
"Token should be transferred"
);
console.log(\` ✓ Token #0 transferred from buyer1 to buyer2\n\`);
// 10. WITHDRAW FUNDS
console.log("8. Owner withdrawing collected funds...");
const finalBalance = await publicClient.getBalance({ address: nft.address });
const ownerBalanceBefore = await publicClient.getBalance({ address: owner.account.address });
const withdrawTx = await nft.write.withdraw();
await publicClient.waitForTransactionReceipt({ hash: withdrawTx });
const ownerBalanceAfter = await publicClient.getBalance({ address: owner.account.address });
const contractBalanceAfter = await publicClient.getBalance({ address: nft.address });
assert.equal(contractBalanceAfter, 0n, "Contract should be empty");
assert(ownerBalanceAfter > ownerBalanceBefore, "Owner should have received funds");
console.log(\` ✓ Withdrew ${finalBalance} wei to owner\`);
console.log(\` ✓ Contract balance: ${contractBalanceAfter} wei\n\`);
console.log("=== All tests passed! ===\n");
});
What this test demonstrates:
This comprehensive test simulates a real NFT project launch:
- Deployment: Create the collection with configuration
- Team allocation: Owner mints tokens for free (airdrops/marketing)
- Public sale: Users buy NFTs by paying the mint price
- Payment validation: Insufficient payment gets rejected
- Dynamic pricing: Owner adjusts price for different sale phases
- Trading: NFTs can be transferred between users
- Revenue collection: Owner withdraws accumulated ETH
This is the actual flow of a successful NFT launch. Your test proves every step works.
3. Run TypeScript Tests
Execute the integration test:
npx hardhat test nodejs test/MyNFT.ts
You should see detailed console output walking through each phase of the NFT lifecycle, confirming everything works together.
Your deployed smart contract controls ownership and minting logic. But NFTs need something more: metadata describing what each token actually represents. NFTs need metadata: the JSON files describing each token's name, description, image, and attributes. This metadata lives off-chain (storing it on Ethereum would cost thousands per NFT), typically on IPFS for decentralized, permanent storage.
NFT metadata follows the ERC-721 Metadata Standard and OpenSea's metadata conventions. Here's what a typical metadata file looks like:
{
"name": "Cool Cat #1",
"description": "A unique cool cat from the Cool Cats collection",
"image": "ipfs://QmX7J9r8UzL2K3M4N5P6Q8R9S0T1U2V3W4X5Y6Z7A8B9C0/1.png",
"attributes": [
{
"trait_type": "Background",
"value": "Blue"
},
{
"trait_type": "Hat",
"value": "Beanie"
},
{
"trait_type": "Rarity",
"value": "Common"
}
]
}
Field breakdown:
name: The token's display name (shows in wallets and marketplaces)description: A brief explanation of what this NFT representsimage: IPFS URI pointing to the actual artwork fileattributes: Traits that define rarity and characteristics (used for filtering on OpenSea)
Setting Up Pinata for IPFS Storage
Pinata is a pinning service that ensures your files stay available on IPFS. Without pinning, IPFS files can disappear when no nodes are hosting them.
1. Create Your Pinata Account
- Visit pinata.cloud and sign up for a free account
- You'll upload files directly through the Pinata dashboard
2. Prepare Sample NFT Assets
For this tutorial, we'll create a small 5-NFT collection. You'll need:
Images: Create or download 5 images (PNG or JPG format, ideally 1000x1000px or larger). Name them 1.png, 2.png, 3.png, 4.png, 5.png.
For testing purposes, you can use any images: abstract patterns, AI-generated art, or simple colored squares. Production NFT projects invest heavily in artwork, but for learning, placeholder images work fine.
Create a folder structure in your project:
mkdir -p nft-assets/images
mkdir -p nft-assets/metadata
Place your images in nft-assets/images/.
3. Upload Images to IPFS (First CID)
⚠️ Important: You'll upload TWO folders separately and get TWO different CIDs:
- Images CID - for your artwork files (this step)
- Metadata CID - for your JSON files (step 5)
Upload your images first:
- Log into your Pinata dashboard
- Click Upload → Folder
- Select your
nft-assets/imagesfolder - Pinata will upload all images and return a CID (Content Identifier)
- Save this Images CID (it looks like:
QmX7J9r8UzL2K3M4N5P6Q8R9S0T1U2V3W4X5Y6Z7A8B9C0)
Your images are now on IPFS. Each image is accessible at:
ipfs://YOUR_IMAGES_CID/1.png
ipfs://YOUR_IMAGES_CID/2.png
...
Now create 5 JSON files in nft-assets/metadata/, one for each NFT.
⚠️ Use your Images CID from step 3 in the "image" field. This is where the two CIDs connect!
1.json:
{
"name": "My NFT #1",
"description": "The first NFT from my collection, featuring unique traits and artwork.",
"image": "ipfs://YOUR_IMAGE_CID/1.png",
"attributes": [
{
"trait_type": "Background",
"value": "Blue"
},
{
"trait_type": "Rarity",
"value": "Common"
}
]
}
2.json:
{
"name": "My NFT #2",
"description": "The second NFT from my collection, featuring unique traits and artwork.",
"image": "ipfs://YOUR_IMAGE_CID/2.png",
"attributes": [
{
"trait_type": "Background",
"value": "Red"
},
{
"trait_type": "Rarity",
"value": "Rare"
}
]
}
Repeat for 3.json, 4.json, and 5.json, adjusting the token numbers and attributes.
Pro tip: Real NFT projects generate these files programmatically. With 10,000 NFTs, you'd write a script combining trait layers. For 5 NFTs, manual creation is faster.
Now upload the metadata folder to get a separate CID:
- Return to Pinata dashboard
- Click Upload → Folder
- Select your
nft-assets/metadatafolder - Save this Metadata CID (this will be different from your Images CID!)
Your metadata is now on IPFS! Each metadata file is accessible at:
ipfs://YOUR_METADATA_CID/1.json
ipfs://YOUR_METADATA_CID/2.json
...
These Metadata URIs are what you'll pass to your contract's mint() function
Summary: Two CIDs, Two Purposes
| CID | Contains | Used In | Example |
|---|---|---|---|
| Images CID | Your artwork files (.png,.jpg) | Inside metadata JSON files ("image" field) | ipfs://QmImages.../1.png |
| Metadata CID | Your JSON files | In your mint() function call | ipfs://QmMeta.../1.json |
The flow works like this: mint() calls tokenURI, which points to Metadata JSON. The "image" field in that JSON points to the actual artwork.
Why IPFS and Not Regular Cloud Storage?
You might wonder: Why not just host these files on AWS S3 or a web server?
Decentralization: IPFS ensures your NFTs remain accessible even if Pinata shuts down. Any IPFS node can serve the content. AWS could delete your files, take down your bucket, or ban your account.
Immutability: IPFS addresses (CIDs) are derived from file content. If someone changes the file, the CID changes. This cryptographic link between address and content guarantees your NFT's metadata can't be secretly modified.
Standard practice: The NFT ecosystem expects ipfs:// URIs. Marketplaces render them automatically. Using HTTP URLs marks your project as amateur and raises concerns about rug pulls (projects that later replace valuable artwork with blank images).
That said, some projects use hybrid approaches: images on IPFS, metadata on fast CDNs. But for learning and most projects, full IPFS is the gold standard.
Setting Up for Deployment
With your contract tested and metadata prepared, it's time to deploy your NFT collection to a public network. We'll use the Sepolia testnet. The same process as mainnet deployment, but with free test ETH.
1. Setting Up Environment Variables
Just like in the ERC-20 guide, we'll use Hardhat's secure keystore to manage sensitive credentials.
Set SEPOLIA_RPC_URL:
Run the command below, create a keystore password, and paste your Alchemy RPC URL.
npx hardhat keystore set SEPOLIA_RPC_URL
Set SEPOLIA_PRIVATE_KEY:
Store your wallet's private key securely. This key signs the deployment transaction.
npx hardhat keystore set SEPOLIA_PRIVATE_KEY
⚠️ Security warning: Your private key controls your funds. Never commit it to version control or share it with anyone.
2. Create the Deployment Script
We'll use Hardhat Ignition for reliable, repeatable deployments.
Create the deployment file:
touch ignition/modules/MyNFT.ts
Setup the deployment script:
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import { parseEther } from "viem";
export default buildModule("MyNFTModule", (m) => {
const name = "My NFT";
const symbol = "MNFT";
const maxSupply = 100n;
const mintPrice = parseEther("0.01");
const myNFT = m.contract("MyNFT", [name, symbol, maxSupply, mintPrice]);
return { myNFT };
});
Parameter decisions:
maxSupply: Set to 100 for this tutorial.mintPrice: 0.01 ETH is reasonable for testnets.- Adjust these values based on your collection's goals.
3. Deploy Your NFT Collection
Execute the deployment:
npx hardhat ignition deploy ignition/modules/MyNFT.ts --network sepolia
You'll see output like this:
[hardhat-keystore] Enter the password: ********
✔ Confirm deploy to network sepolia (11155111)? … yes
Hardhat Ignition 🚀
Deploying [ MyNFTModule ]
Batch #1
Executed MyNFTModule#MyNFT
[ MyNFTModule ] successfully deployed 🚀
Deployed Addresses
MyNFTModule#MyNFT - 0x03227D9BcdC5567e711E8aDEcbb13460A6299b97
Save your contract address. You'll need it for minting and verification.
Interacting with Your NFT Contract
Your collection is live on Sepolia. Let's mint some NFTs using the IPFS metadata you prepared earlier.
Creating a Mint Script
We'll create a script to mint your first NFT. This script connects to your deployed contract and calls the mint() function with payment.
Create the script:
touch scripts/mint.ts
Add the minting logic:
import { network } from "hardhat";
import { parseEther } from "viem";
async function main() {
const { viem } = await network.connect();
const [minter] = await viem.getWalletClients();
// Replace with your actual contract address from deployment
const contractAddress = "0x03227D9BcdC5567e711E8aDEcbb13460A6299b97";
// Replace with your METADATA CID from Pinata
// This is the CID you got when uploading the metadata/ folder
const metadataCID = "YOUR_METADATA_CID";
const nft = await viem.getContractAt("MyNFT", contractAddress);
console.log("Minting NFT #1...");
const mintTx = await nft.write.mint(
[minter.account.address, \`ipfs://${metadataCID}/1.json\`],
{
value: parseEther("0.01"),
account: minter.account
}
);
console.log(\`Transaction hash: ${mintTx}\`);
console.log("Waiting for confirmation...");
const publicClient = await viem.getPublicClient();
await publicClient.waitForTransactionReceipt({ hash: mintTx });
console.log("✅ NFT minted successfully!");
console.log(\`Total supply: ${await nft.read.totalSupply()}\`);
console.log(\`Owner: ${await nft.read.ownerOf([0n])}\`);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
Run the mint script:
npx hardhat run scripts/mint.ts --network sepolia
Mint multiple NFTs:
To mint your entire collection, modify the script to loop through your metadata files:
// Mint NFTs 1 through 5
for (let i = 1; i <= 5; i++) {
console.log(\`Minting NFT #${i}...\`);
const tx = await nft.write.mint(
[minter.account.address, \`ipfs://${metadataCID}/${i}.json\`],
{ value: parseEther("0.01"), account: minter.account }
);
await publicClient.waitForTransactionReceipt({ hash: tx });
console.log(\`✅ NFT #${i} minted!\`);
}
Viewing Your NFTs on Rarible
Rarible's testnet marketplace automatically indexes Sepolia NFTs. To see your collection:
- Visit testnet.rarible.com
- Connect your MetaMask wallet (ensure you're on Sepolia network)
- Navigate to your profile
- Your minted NFTs should appear within a few minutes
Alternatively, view your collection directly by constructing the URL:
https://testnet.rarible.com/collection/YOUR_CONTRACT_ADDRESS:TOKEN_ID
Replace YOUR_CONTRACT_ADDRESS with your deployed contract address and TOKEN_ID with the token number (0, 1, 2, etc.).
Troubleshooting: If NFTs don't appear immediately:
- Wait a few minutes for Rarible's indexer to process the contract
- Verify your transaction succeeded on Sepolia Etherscan
- Check that your IPFS metadata is accessible via Pinata's gateway
Using Your NFT Contract's Admin Functions
As the contract owner, you have special privileges. Let's test them.
Free owner mint (airdrop):
Create scripts/ownerMint.ts:
import { network } from "hardhat";
async function main() {
const { viem } = await network.connect();
const [owner] = await viem.getWalletClients();
const contractAddress = "0x03227D9BcdC5567e711E8aDEcbb13460A6299b97";
const metadataCID = "YOUR_METADATA_CID";
const recipientAddress = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb"; // Friend's address
const nft = await viem.getContractAt("MyNFT", contractAddress);
console.log("Owner minting NFT (no payment required)...");
const tx = await nft.write.ownerMint(
[recipientAddress, \`ipfs://${metadataCID}/6.json\`]
);
const publicClient = await viem.getPublicClient();
await publicClient.waitForTransactionReceipt({ hash: tx });
console.log("✅ Airdrop successful!");
}
main().catch(console.error);
Update mint price:
// Change mint price to 0.02 ETH
const tx = await nft.write.setMintPrice([parseEther("0.02")]);
await publicClient.waitForTransactionReceipt({ hash: tx });
console.log("✅ Mint price updated!");
Withdraw collected funds:
After people mint, you'll want to collect the accumulated ETH:
const contractBalance = await publicClient.getBalance({ address: contractAddress });
console.log(\`Contract balance: ${contractBalance} wei\`);
const tx = await nft.write.withdraw();
await publicClient.waitForTransactionReceipt({ hash: tx });
console.log("✅ Funds withdrawn to owner!");
Conclusion
ou've built and deployed a complete NFT collection. Here's what you accomplished:
- Built a production-ready ERC-721 smart contract with public minting, owner privileges, supply caps, and revenue collection. The same architecture powering billion-dollar NFT projects.
- Mastered NFT metadata standards by creating properly structured JSON files, uploading them to IPFS via Pinata, and understanding why decentralized storage matters.
- Implemented comprehensive testing using both Solidity unit tests and TypeScript integration tests, covering payment validation, access control, and the complete NFT lifecycle.
- Deployed to a public blockchain using Hardhat Ignition and secure credential management, making your collection accessible to anyone with an Ethereum wallet.
- Minted real NFTs that appear in MetaMask and on Rarible, experiencing the same flow users follow when participating in NFT drops.
What You Built vs. Production NFT Projects
Your contract contains the core mechanisms behind successful NFT collections:
- Payment-gated minting: The revenue model that generated $2 billion+ during the 2021-2022 NFT boom
- Supply caps: The scarcity mechanism that drives value (Bored Apes' 10,000 limit)
- Metadata linking: The IPFS integration that separates ownership (on-chain) from assets (off-chain)
- Owner controls: The administrative functions teams use for airdrops and price management
What's missing from production projects?
- Whitelist/allowlist: Many projects restrict early mints to approved addresses
- Reveal mechanics: Some collections hide metadata initially, revealing artwork after sellout
- Royalties: ERC-2981 standard for automatic creator fees on secondary sales
- Gas optimization: Techniques like ERC-721A for batch minting at reduced cost
These are enhancements, not fundamentals. You've built the foundation.