Creating non-fungible tokens (NFTs) on Ethereum has become a foundational skill for blockchain developers, artists, and entrepreneurs alike. With the ERC-721 standard, you can mint unique digital assets that represent everything from digital art to in-game items. This comprehensive guide walks you through the entire process of building, testing, and deploying an ERC-721 NFT smart contract using OpenZeppelin, Hardhat, and IPFS—all while ensuring your project is production-ready and compatible with marketplaces like OpenSea.
Whether you're new to smart contracts or looking to refine your NFT development workflow, this tutorial delivers practical insights with real-world code examples.
Understanding Tokens and the ERC-721 Standard
Before diving into coding, it’s essential to understand what tokens are and how NFTs differ from their fungible counterparts.
Fungible vs. Non-Fungible Tokens
On the blockchain, a token represents ownership of a digital or physical asset. There are two primary types:
- Fungible Tokens: Interchangeable and identical—like cryptocurrencies such as ETH or stablecoins like USDT.
- Non-Fungible Tokens (NFTs): Each token is unique, with distinct properties—similar to owning a rare painting or a specific domain name.
The ERC-721 standard is Ethereum’s most widely adopted framework for creating NFTs. It defines a set of rules that ensure interoperability across wallets, exchanges, and marketplaces.
👉 Start building your first NFT collection today with confidence.
Core Functions of ERC-721
An ERC-721 compliant contract must implement key functions such as:
balanceOf(address)– Returns the number of NFTs owned by an address.ownerOf(uint256 tokenId)– Identifies who owns a specific token.safeTransferFrom()– Securely transfers ownership.approve()andsetApprovalForAll()– Allow third-party management of tokens.
It also emits events like Transfer, Approval, and ApprovalForAll to track changes on-chain.
While you could write these functions from scratch, OpenZeppelin provides secure, audited implementations—saving time and reducing risk.
Building a Basic ERC-721 Contract Using OpenZeppelin
We’ll use the OpenZeppelin Solidity Wizard to generate our base contract. This tool simplifies setup by letting you select features via a UI.
Let’s create a collection called "Football Players" with the symbol FTP. We'll enable:
- Mintable: Allows creation of new tokens.
- Auto Increment IDs: Automatically assigns sequential token IDs.
- Ownable: Restricts minting to the contract owner.
Here’s the generated Solidity code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract FootballPlayers is ERC721, Ownable {
uint256 private _nextTokenId;
constructor() ERC721("Football Players", "FTP") Ownable(msg.sender) {}
function safeMint(address to) public onlyOwner {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
}
}Key Components Explained
Imports
The contract inherits from ERC721 and Ownable. This gives us all standard NFT functionality plus access control—only the deployer can mint.
Constructor
Initializes the token name and symbol. The Ownable constructor sets the deployer as the owner.
safeMint Function
Uses _safeMint() to ensure the recipient can handle NFTs (e.g., supports ERC-721 receiver logic). The onlyOwner modifier prevents unauthorized minting.
Setting Up a Hardhat Development Environment
Hardhat is a powerful Ethereum development environment used for compiling, testing, and deploying smart contracts.
Initialize Your Project
Run these commands in your terminal:
npm init -y
npm install --save-dev hardhat
npx hardhat init # Choose TypeScript option
npm install --save-dev dotenv
npm install @openzeppelin/contracts
npm install --save-dev @nomicfoundation/hardhat-ignition-ethersConfigure Environment Variables
Create a .env file:
PRIVATE_KEY="your_private_key"
INFURA_SEPOLIA_ENDPOINT="your_infura_sepolia_url"Update hardhat.config.ts to connect to the Sepolia testnet:
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import dotenv from 'dotenv';
dotenv.config();
const config: HardhatUserConfig = {
solidity: "0.8.24",
networks: {
sepolia: {
url: process.env.INFURA_SEPOLIA_ENDPOINT,
accounts: [process.env.PRIVATE_KEY ?? ""]
}
}
};
export default config;Write Tests
A well-tested contract ensures reliability. Here’s a sample test verifying ownership and minting logic:
import { expect } from "chai";
import { ethers } from "hardhat";
describe("FootballPlayers", () => {
it("Should assign correct name and symbol", async () => {
const FootballPlayers = await ethers.getContractFactory("FootballPlayers");
const contract = await FootballPlayers.deploy();
expect(await contract.name()).to.equal("Football Players");
expect(await contract.symbol()).to.equal("FTP");
});
it("Should only allow owner to mint", async () => {
const [owner, addr1] = await ethers.getSigners();
const contract = await ethers.getContractFactory("FootballPlayers");
const deployed = await contract.deploy();
await deployed.safeMint(owner.address);
await expect(deployed.connect(addr1).safeMint(addr1.address))
.to.be.revertedWithCustomError(deployed, "OwnableUnauthorizedAccount");
});
});Compile and test:
npx hardhat compile
npx hardhat testDeploy using Ignition:
npx hardhat ignition deploy ignition/modules/FootballPlayers.ts --network sepoliaAdding Metadata with IPFS
NFTs gain value through metadata—image, name, description. Storing this off-chain via IPFS is cost-effective and decentralized.
Upload Assets to IPFS
- Generate images using AI tools (like DALL-E).
- Save each as
0.json,1.json, etc., with content like:
{
"name": "Football Player #0",
"description": "An AI-generated Italian national team forward.",
"image": "ipfs://bafkreicdgbzytjd7a3yjd446wg56meafmixwu6rxe2zhcc6psbzxjrnkkm"
}- Upload all files to nft.storage using NFTUp.
- Get the root folder hash:
ipfs://bafybeiag6fokmiz6xmjodjyeuejtgrbyf2moirydovrew2bhmxjrehernq
Update Contract to Support tokenURI
Override _baseURI() and tokenURI():
function _baseURI() internal pure override returns (string memory) {
return "ipfs://bafybeiag6fokmiz6xmjodjyeuejtgrbyf2moirydovrew2bhmxjrehernq/";
}
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
_requireOwned(tokenId);
return string(abi.encodePacked(_baseURI(), Strings.toString(tokenId), ".json"));
}Now each token resolves its metadata dynamically.
Enhancing Your NFT with Advanced Features
Let’s upgrade our contract with more powerful extensions from OpenZeppelin:
ERC721Enumerable: Enables querying total supply and listing all tokens.ERC721URIStorage: Stores URIs on-chain for flexibility.ERC721Burnable: Allows users to destroy tokens.ERC721Pausable: Lets owner pause transfers during emergencies.
Updated contract snippet:
contract FootballPlayers is ERC721, ERC721Enumerable, ERC721URIStorage,
ERC721Pausable, Ownable, ERC721Burnable {
uint256 private _nextTokenId;
function safeMint(address to, string memory uri) public onlyOwner whenNotPaused {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
}
function pause() public onlyOwner {
_pause();
}
function unpause() public onlyOwner {
_unpause();
}
// Required overrides due to multiple inheritance
function _update(address to, uint256 tokenId, address auth)
internal
override(ERC721, ERC721Enumerable, ERC721Pausable)
returns (address)
{
return super._update(to, tokenId, auth);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721Enumerable, ERC721URIStorage)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}These enhancements make your NFT collection more secure, flexible, and user-friendly.
👉 Unlock full potential—turn ideas into deployable NFT projects now.
Frequently Asked Questions (FAQ)
Q: What is the difference between ERC-721 and ERC-1155?
A: ERC-721 creates one-of-a-kind tokens, ideal for collectibles. ERC-1155 supports both fungible and non-fungible tokens in one contract—perfect for gaming assets.
Q: Can I change metadata after deployment?
A: If using _baseURI() with IPFS, no—IPFS is immutable. Use ERC721URIStorage if you need updatable URIs (at higher gas cost).
Q: How do I list my NFT on OpenSea?
A: After deploying to Sepolia or mainnet, visit testnets.opensea.io and search your contract address. It will auto-detect and display your collection.
Q: Is it safe to use OpenZeppelin contracts?
A: Yes—OpenZeppelin contracts are community-audited, battle-tested, and widely trusted across DeFi and NFT platforms.
Q: Why use Hardhat instead of Remix?
A: Hardhat offers advanced testing, scripting, and CI/CD integration—ideal for professional development. Remix is great for quick prototyping.
Q: How much does it cost to deploy an NFT contract?
A: On Ethereum mainnet, expect $50–$500 depending on network congestion. Testnets like Sepolia are free.
Final Thoughts
You now have the tools and knowledge to create robust, standards-compliant ERC-721 NFTs on Ethereum. From setting up your development environment with Hardhat to integrating metadata via IPFS and enhancing functionality with OpenZeppelin extensions—you're equipped for real-world deployment.
As the NFT ecosystem evolves, mastering these fundamentals opens doors to innovation in digital ownership, gaming, identity, and beyond.
👉 Take the next step—deploy your vision securely and efficiently.