Minting Your First NFT Collection

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

MetaMask wallet

Holds test funds and signs the deployment transaction that creates your contract

Sepolia test ETH

Pays for gas fees (~$0 in real value, but required for test network)

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:

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 Y to install

Navigate to your new project:

2. Install OpenZeppelin Contracts

Install OpenZeppelin's audited contract library:

3. Create Your NFT Contract File

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:

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: Provides onlyOwner access 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:

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:

Key concepts:

  • payable modifier: 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:

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:

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:

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:

Add the test contract with setup function:

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:

NFT collections start empty. Users mint tokens after deployment.

3. Test Public Minting

Test the payment-based minting flow:

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:

5. Test Price Management

Verify only the owner can adjust pricing:

6. Test Withdrawals

Verify the owner can collect sales revenue:

7. Test ERC721 Standard

Verify token transfers work correctly:

These verify ERC-721 compliance. Marketplaces rely on these functions working correctly for trading NFTs.

8. Run Solidity Tests

Execute your test suite:

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:

Add the test structure:

2. Implement Complete NFT Lifecycle Test

This single test covers the entire NFT project lifecycle:

What this test demonstrates:

This comprehensive test simulates a real NFT project launch:

  1. Deployment: Create the collection with configuration

  2. Team allocation: Owner mints tokens for free (airdrops/marketing)

  3. Public sale: Users buy NFTs by paying the mint price

  4. Payment validation: Insufficient payment gets rejected

  5. Dynamic pricing: Owner adjusts price for different sale phases

  6. Trading: NFTs can be transferred between users

  7. 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:

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:

Field breakdown:

  • name: The token's display name (shows in wallets and marketplaces)

  • description: A brief explanation of what this NFT represents

  • image: IPFS URI pointing to the actual artwork file

  • attributes: 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

  1. Visit pinata.cloud and sign up for a free account

  2. 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:

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:

  1. Images CID - for your artwork files (this step)

  2. Metadata CID - for your JSON files (step 5)

Upload your images first:

  1. Log into your Pinata dashboard

  2. Click Upload β†’ Folder

  3. Select your nft-assets/images folder

  4. Pinata will upload all images and return a CID (Content Identifier)

  5. Save this Images CID (it looks like: QmX7J9r8UzL2K3M4N5P6Q8R9S0T1U2V3W4X5Y6Z7A8B9C0)

Your images are now on IPFS. Each image is accessible at:

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:

2.json:

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:

  1. Return to Pinata dashboard

  2. Click Upload β†’ Folder

  3. Select your nft-assets/metadata folder

  4. Save this Metadata CID (this will be different from your Images CID!)

Your metadata is now on IPFS! Each metadata file is accessible at:

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.

Set SEPOLIA_PRIVATE_KEY: Store your wallet's private key securely. This key signs the deployment transaction.

⚠️ 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:

Setup the deployment script:

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:

You'll see output like this:

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:

Add the minting logic:

Run the mint script:

Mint multiple NFTs:

To mint your entire collection, modify the script to loop through your metadata files:

Viewing Your NFTs on Rarible

Rarible's testnet marketplace automatically indexes Sepolia NFTs. To see your collection:

  1. Connect your MetaMask wallet (ensure you're on Sepolia network)

  2. Navigate to your profile

  3. Your minted NFTs should appear within a few minutes

Alternatively, view your collection directly by constructing the URL:

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:

Update mint price:

Withdraw collected funds:

After people mint, you'll want to collect the accumulated ETH:

Conclusion

ou've built and deployed a complete NFT collection. Here's what you accomplished:

  1. 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.

  2. Mastered NFT metadata standards by creating properly structured JSON files, uploading them to IPFS via Pinata, and understanding why decentralized storage matters.

  3. Implemented comprehensive testing using both Solidity unit tests and TypeScript integration tests, covering payment validation, access control, and the complete NFT lifecycle.

  4. Deployed to a public blockchain using Hardhat Ignition and secure credential management, making your collection accessible to anyone with an Ethereum wallet.

  5. 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.

Last updated