The world of Non-Fungible Tokens (NFTs) is built upon foundational standards like ERC-721, which enables unique digital asset ownership on the blockchain. This comprehensive tutorial will guide you through the process of developing your very own ERC-721 NFT project, combining the robust smart contract development environment of Foundry, the decentralized storage capabilities of IPFS, and an intuitive React frontend for a complete minting experience. Prepare to dive deep into smart contract logic, off-chain metadata management, and web3 frontend integration.
Project Highlights: What You’ll Be Building
By the end of this guide, you will have developed a functional NFT ecosystem comprising:
MyCollectibleSmart Contract: A secure, OpenZeppelin-based ERC-721 contract designed for minting unique digital collectibles and linking to their metadata viatokenURI.- Foundry Toolchain Mastery: Scripts and tests built with Foundry for efficient compilation, rigorous testing, and seamless deployment to a local Anvil node.
- Interactive Minting Dapp (React + Ethers.js): A user-friendly React application integrated with Ethers.js to connect a MetaMask wallet and initiate NFT minting transactions, referencing IPFS metadata URIs.
- IPFS Integration: Implement a helper script to streamline the upload of your NFT’s images and metadata to nft.storage, leveraging the power of IPFS and Filecoin for permanent, content-addressed storage.
Repository Structure at a Glance
Understanding the project’s layout is key to navigation. Here’s how your nft-project directory will be organized:
nft-project/
├─ contracts/
│ └─ MyCollectible.sol
├─ script/
│ └─ Deploy.s.sol
├─ test/
│ └─ MyCollectible.t.sol
├─ frontend/
│ ├─ package.json
│ └─ src/
│ ├─ App.jsx
│ ├─ nftService.js
│ └─ abi.json
├─ tools/
│ └─ upload-to-nftstorage.js
├─ foundry.toml
└─ README.md
Essential Tools and Setup (Prerequisites)
Before we begin our coding adventure, ensure you have the following installed and configured:
- Node.js (v18+ recommended): For running JavaScript-based tools and the React frontend.
- npm or Yarn: Node.js package managers.
- Foundry (forge/anvil): The comprehensive toolkit for Ethereum application development. If you don’t have it, follow the installation guides at getfoundry.sh.
- MetaMask: A browser extension wallet essential for testing our frontend’s interaction with the blockchain.
- An nft.storage API Key (Optional but Recommended): A free key from nft.storage will allow you to pin your assets to IPFS and Filecoin, ensuring their availability.
Step 1: Setting Up Your Foundry Project with OpenZeppelin
Our journey begins by initializing a new Foundry project, which will serve as the foundation for our smart contracts. We’ll also integrate OpenZeppelin Contracts, a highly recommended library for secure and battle-tested smart contract components.
From your desired project root, execute these commands:
forge init nft-project
cd nft-project
# For this tutorial, we'll use OpenZeppelin v4.9.3 for compatibility with Counters,
# or you can use the latest v5.x with an internal counter as shown later.
forge install OpenZeppelin/[email protected] --no-git
Note: Earlier versions of OpenZeppelin included Counters.sol. If opting for the newer v5.x, you can implement a simple uint counter directly within your contract, a method we’ll demonstrate. For more details, consult the OpenZeppelin Documentation.
Step 2: Developing the ERC-721 Smart Contract
Now, let’s create our core ERC-721 contract. Inside the contracts/ directory, create a file named MyCollectible.sol. This contract will leverage ERC721URIStorage from OpenZeppelin to easily manage token metadata pointers.
Here’s the Solidity code for MyCollectible.sol using an internal counter, which works seamlessly with recent OpenZeppelin versions:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title MyCollectible — simple ERC721 collectibles
/// @notice Owner can mint collectibles and set tokenURI pointing to metadata (IPFS allowed)
contract MyCollectible is ERC721URIStorage, Ownable {
uint256 private _tokenIds; // Internal counter for token IDs
constructor() ERC721("MyCollectible", "MYC") {}
/// @notice Mints a new collectible to the specified recipient and sets its tokenURI.
function mintTo(address recipient, string memory tokenURI) public onlyOwner returns (uint256) {
_tokenIds += 1; // Increment the token ID
uint256 newItemId = _tokenIds;
_mint(recipient, newItemId); // Mint the NFT
_setTokenURI(newItemId, tokenURI); // Set the metadata URI
return newItemId;
}
/// @notice Returns the total number of NFTs minted so far.
function totalMinted() external view returns (uint256) {
return _tokenIds;
}
}
Why ERC721URIStorage? This extension is invaluable for managing off-chain metadata. It provides the _setTokenURI function, allowing us to associate a unique URI (typically an IPFS hash) with each NFT. This approach is gas-efficient and ensures that complex metadata isn’t directly stored on the blockchain, while still adhering to the core ERC-721 standard for ownership and transfer functionalities. For more insights, refer to the OpenZeppelin ERC-721 documentation.
Step 3: Crafting the Foundry Deployment Script
With our smart contract ready, we need a script to deploy it to the blockchain. Foundry makes this straightforward with Solidity-based deployment scripts. Create script/Deploy.s.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import "../contracts/MyCollectible.sol";
contract DeployMyCollectible is Script {
function run() external {
vm.startBroadcast(); // Begins a broadcast of transactions
new MyCollectible(); // Deploys an instance of MyCollectible
vm.stopBroadcast(); // Ends the broadcast
}
}
Step 4: Comprehensive Testing with Foundry
Thorough testing is paramount for smart contract security. Foundry provides a powerful testing framework that allows us to write tests in Solidity itself. Create test/MyCollectible.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../contracts/MyCollectible.sol";
contract MyCollectibleTest is Test {
MyCollectible nft;
address ownerAddr; // The address of the contract owner (usually address(this) in tests)
address user = address(0xBEEF); // A test user address
function setUp() public {
ownerAddr = address(this); // Set the test contract's address as the owner
nft = new MyCollectible(); // Deploy a new instance of our NFT contract for each test
}
function testOwnerMint() public {
string memory uri = "ipfs://test-metadata";
uint256 id = nft.mintTo(user, uri); // Owner mints to user
assertEq(id, 1); // Check if the first minted ID is 1
assertEq(nft.ownerOf(1), user); // Verify user owns the NFT
assertEq(nft.totalMinted(), 1); // Confirm total minted count
assertEq(nft.tokenURI(1), uri); // Check if token URI is correctly set
}
function testOnlyOwner() public {
// Simulate a call from a non-owner address
vm.prank(address(0x1234));
vm.expectRevert(); // Expect the transaction to revert
nft.mintTo(address(0x9999), "ipfs://x"); // Attempt to mint as non-owner
}
}
To execute these tests and verify your contract’s logic, run:
forge test
Step 5: Building and Deploying to a Local Anvil Blockchain
For rapid local development and testing, Anvil (part of Foundry) provides a personal Ethereum blockchain.
- Start Anvil: Open a new terminal and run:
anvil
# Anvil will output the RPC URL (e.g., http://127.0.0.1:8545) and a list of pre-funded test accounts. - Compile and Deploy: In another terminal, compile your contract and deploy it to your running Anvil instance:
forge build
forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast
Theforge scriptcommand will print the deployed contract address. Make sure to copy this address, as it will be required for our frontend application. Foundry’s scripting capabilities, combined with Anvil, offer an incredibly fluid local development and deployment workflow. Learn more about Foundry scripting at getfoundry.sh.
Step 6: Decentralized Metadata and IPFS Integration with nft.storage
One of the cornerstones of modern NFTs is the use of off-chain metadata storage, typically on IPFS (InterPlanetary File System). This approach dramatically reduces gas costs and leverages content addressing for immutable, verifiable data. Services like nft.storage (powered by IPFS and Filecoin) make pinning your metadata easy and reliable.
Why IPFS for NFT Metadata? Storing metadata directly on the blockchain would be prohibitively expensive for most NFTs, especially those with rich media. IPFS offers a decentralized, content-addressed solution, meaning the data itself (not its location) determines its identifier (CID). This ensures that if the data exists, it can always be retrieved via its CID, contributing to the “permanence” often associated with NFTs. Adhering to IPFS best practices for NFT data is crucial for ensuring the longevity and accessibility of your digital assets.
Let’s create a helper script tools/upload-to-nftstorage.js to upload images and generate the necessary metadata JSON:
// tools/upload-to-nftstorage.js
// Usage: NFT_STORAGE_KEY=your_key node tools/upload-to-nftstorage.js ./assets/my-image.png "My Collectible #1" "A description"
import fs from "fs";
import path from "path";
import fetch from "node-fetch"; // Node-fetch might need to be installed: npm install node-fetch
const API = "https://api.nft.storage/upload";
async function main() {
const key = process.env.NFT_STORAGE_KEY;
if (!key) throw new Error("Set NFT_STORAGE_KEY env var");
const [, , imagePath, name, description] = process.argv;
if (!imagePath || !name) throw new Error("Usage: node upload-to-nftstorage.js <image> <name> [description]");
const imageData = fs.readFileSync(path.resolve(imagePath));
// In a real-world scenario, you might upload the image and metadata separately.
// For simplicity, this script uploads the image data directly.
// A more robust script would use the nft.storage NPM package to handle metadata JSON generation and multi-file uploads.
// Note: The original example had a 'metadata' object crafted here.
// However, the fetch call only sends `imageData`.
// For accurate metadata upload, use the nft.storage client SDK or structure your fetch call to send JSON metadata
// that *references* the IPFS CID of the image after it's uploaded.
// The below example only uploads the image data.
// For a complete flow (image + metadata JSON), consider the official SDK.
const resp = await fetch(API, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
Accept: "application/json"
},
body: imageData // Directly uploading the image data
});
if (!resp.ok) {
console.error("Upload failed", await resp.text());
process.exit(1);
}
const j = await resp.json();
console.log("Upload result:", j);
console.log(`Your image IPFS CID is: ${j.value.cid}`);
// In a full workflow, you would then craft a metadata JSON file referencing this CID
// and upload that JSON file separately to nft.storage to get its CID.
}
main().catch(e => { console.error(e); process.exit(1); });
Important Note: While the script above provides a basic upload, for a truly robust solution that uploads both your image and correctly structured metadata JSON (referencing the image’s IPFS CID), it’s highly recommended to use the official nft.storage JavaScript SDK. This SDK simplifies the process of bundling multiple files and metadata into a single upload.
Example Metadata JSON Structure:
Your tokenURI will point to a JSON file (stored on IPFS) that follows a standard structure, like this:
{
"name": "MyCollectible #1",
"description": "First collectible in the series",
"image": "ipfs://bafybe.../image.png", // This is the IPFS URI to your image
"attributes": [
{ "trait_type": "rarity", "value": "common" }
]
}
The process involves:
1. Uploading your image(s) to nft.storage to obtain their ipfs://<CID>/image.png URI.
2. Crafting a metadata JSON file that includes this image URI.
3. Uploading that JSON file to nft.storage to get its own ipfs://<CID>/metadata.json URI. This final metadata URI is what you will use when minting your NFT.
Step 7: Building the Frontend with React and Ethers.js
Now, let’s create a minimal decentralized application (dApp) using React and Ethers.js to interact with our smart contract. Start by setting up a new Vite React project within the frontend/ directory and install ethers@6.
First, you’ll need your contract’s Application Binary Interface (ABI). Copy the ABI from Foundry’s artifact output (typically found in out/MyCollectible.sol/MyCollectible.json) into frontend/src/abi.json.
frontend/src/nftService.js: This file will encapsulate our web3 interaction logic.
import { ethers } from "ethers";
import abi from "./abi.json"; // Your contract's ABI
const CONTRACT_ADDRESS = "<PASTE_DEPLOYED_ADDRESS>"; // IMPORTANT: Replace with your actual deployed contract address
export async function connectWallet() {
if (!window.ethereum) throw new Error("No wallet found. Please install MetaMask.");
await window.ethereum.request({ method: "eth_requestAccounts" }); // Request user's accounts
const provider = new ethers.BrowserProvider(window.ethereum); // Create an Ethers.js provider
return provider;
}
export async function mintNFT(tokenURI) {
const provider = await connectWallet();
const signer = await provider.getSigner(); // Get the signer (connected account)
const contract = new ethers.Contract(CONTRACT_ADDRESS, abi, signer); // Instantiate contract with signer
// Call the mintTo function. Note: In our contract, this is `onlyOwner`.
// For local testing, ensure the connected MetaMask account is one of Anvil's pre-funded accounts
// that acts as the owner.
const user = await signer.getAddress();
const tx = await contract.mintTo(user, tokenURI);
await tx.wait(); // Wait for the transaction to be mined
return tx.hash; // Return the transaction hash
}
frontend/src/App.jsx: This is our main React component, providing a simple UI for minting.
import React, { useState } from "react";
import { mintNFT } from "./nftService";
export default function App() {
const [uri, setUri] = useState(""); // State for the token URI input
const [status, setStatus] = useState(""); // State for displaying messages
const handleMint = async () => {
try {
setStatus("Minting...");
const tx = await mintNFT(uri); // Call the mintNFT function
setStatus(`Minted successfully! Transaction Hash: ${tx}`);
setUri(""); // Clear the input field
} catch (e) {
setStatus(`Error: ${e.message}. Ensure MetaMask is connected to Anvil and the account is the contract owner.`);
}
};
return (
<div style={{ padding: 40, fontFamily: 'Arial, sans-serif' }}>
<h1>My Collectible — NFT Minting Portal</h1>
Enter the IPFS URI for your NFT's metadata (e.g., ipfs://Qm.../metadata.json)
<input
type="text"
placeholder="ipfs://..."
value={uri}
onChange={e => setUri(e.target.value)}
style={{ width: '60%', padding: '10px', marginRight: '10px', borderRadius: '5px', border: '1px solid #ccc' }}
/>
<button
onClick={handleMint}
style={{ padding: '10px 20px', borderRadius: '5px', border: 'none', background: '#4CAF50', color: 'white', cursor: 'pointer' }}
>
Mint NFT
</button>
{status && <p style={{ marginTop: '20px', fontWeight: 'bold' }}>Status: {status}</p>}
</div>
);
}
Running the Dev Server:
Navigate to your frontend/ directory and start the React development server:
cd frontend
npm install
npm run dev
# Open your browser to http://localhost:5173 to see your dApp.
Crucial for Local Testing: To successfully mint from your browser against your local Anvil instance:
* Add Anvil to MetaMask: Configure a custom network in MetaMask using Anvil’s RPC URL (e.g., http://127.0.0.1:8545`) and its Chain ID (usually 31337). Anvil will display these details on startup.mintTo
* **Import Owner Account:** Import one of Anvil's pre-funded private keys into MetaMask. Since ourfunction isonlyOwner`, this MetaMask account must be the deployed contract’s owner. For a detailed guide on this, refer to resources like Cyfrin Updraft’s MetaMask setup for local chains.
Step 8: The Complete End-to-End Local Workflow
Here’s a consolidated sequence of commands to run your entire NFT project locally:
- Start Your Local Blockchain (Anvil):
anvil - Deploy Your Smart Contract:
forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast - Update Frontend with Contract Address: Copy the contract address printed by
forge scriptand paste it intofrontend/src/nftService.js, replacing<PASTE_DEPLOYED_ADDRESS>. - Launch Your Frontend Dapp:
cd frontend
npm run dev - Upload Assets & Mint: Use your
upload-to-nftstorage.jsscript (or the official SDK) to upload your NFT images and metadata to nft.storage. Obtain the finalipfs://metadata URI, then paste it into your running React frontend and initiate the minting process via MetaMask.
Step 9: Security and Best Practices for NFT Development
Developing secure and robust NFT projects requires adherence to industry best practices:
- Leverage OpenZeppelin: Always utilize audited and well-tested implementations like those provided by OpenZeppelin Contracts for ERC-721 to minimize security vulnerabilities.
- Off-Chain Media Storage: Keep large media files off the blockchain, storing them on decentralized networks like IPFS/Filecoin. Reference these assets using Content Identifiers (CIDs) within your
tokenURIto maintain low gas costs and benefit from content addressing. More on this can be found in IPFS best practices. - ReentrancyGuard for ETH Transfers: If your contract involves Ether transfers or complex marketplace interactions, consider integrating OpenZeppelin’s
ReentrancyGuardto prevent reentrancy attacks. - Metadata Immutability: Understand the implications of IPFS. Once metadata is uploaded and referenced by its CID, it’s immutable. If you require mutable metadata, you’ll need to design your contract with upgradable metadata pointers, on-chain metadata, or a mutable gateway mapping solution.
Authoritative References for Deeper Understanding
- EIP-721: Non-Fungible Token Standard: The foundational specification for NFTs on Ethereum.
- OpenZeppelin ERC-721 Documentation: Detailed information and secure implementations for ERC-721 tokens.
- Foundry Book – Scripting with Solidity: Comprehensive guide to using Foundry for deployment and local development workflows.
- nft.storage: A service for uploading and pinning NFT metadata and assets to IPFS and Filecoin.
- IPFS Best Practices for NFT Data: Guidance on structuring and storing NFT data on IPFS.
Further Customization and Considerations
This guide provides a solid foundation. Here are ideas for extending your project:
- Public Minting: To allow anyone to mint, remove the
onlyOwnermodifier from themintTofunction. You would typically add logic to require a specific mint price (require(msg.value == price)) and implement supply caps. - Royalties (ERC-2981): For secondary market sales, consider integrating the ERC-2981 Royalty Standard.
- Metadata Mutability Policies: Define a clear policy for whether and how your NFT metadata can be updated.
- Marketplace Integration: Explore how to list your NFTs on popular marketplaces like OpenSea or Rarible.