Create unique digital collectibles (NFTs) using the ERC-721 standard, store metadata on IPFS, test & deploy locally with Foundry (Anvil), and mint from a tiny React + Ethers.js frontend.
Why this matters: ERC-721 defines a standard interface for NFTs, letting wallets, marketplaces, and tooling work the same way across projects. Use OpenZeppelin to avoid reinventing secure token code, use Foundry for fast local development & testing, and store metadata off-chain (IPFS/nft.storage) to keep gas costs low and ensure content-addressed permanence. ([Ethereum Improvement Proposals][1])
TL;DR — What you’ll build
-
MyCollectible
— a secure ERC-721 that mints NFTs and storestokenURI
. - Foundry scripts/tests to compile, test, and deploy to a local Anvil node.
- A React + Ethers.js frontend to connect MetaMask and mint an NFT with an
ipfs://
metadata URI. - Helper script to upload images + metadata to nft.storage (IPFS & Filecoin). ([nft.storage][2])
Quick repo layout
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
Prerequisites
- Node.js (v18+ recommended) and npm/yarn
- Foundry (forge/anvil) installed — see Foundry guides. ([getfoundry.sh][3])
- MetaMask (for frontend testing)
- nft.storage API key (free) if you want to pin to IPFS/Filecoin. ([nft.storage][2])
1) Initialize Foundry project & OpenZeppelin
From project root:
forge init nft-project
cd nft-project
# install OpenZeppelin v4.9.3 (contains Counters if you prefer) or use latest (v5.x) and an internal counter
forge install OpenZeppelin/openzeppelin-contracts@v4.9.3 --no-git
Note: earlier versions of OpenZeppelin included
Counters.sol
. If you choose the newer v5.x you can replace Counters with a simple uint counter — both approaches are shown below. ([OpenZeppelin Docs][4])
2) Solidity contract (ERC-721)
Create contracts/MyCollectible.sol
.
I use an internal counter (works with latest OZ) — safe & simple:
// 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;
constructor() ERC721("MyCollectible", "MYC") {}
/// @notice Mint collectible to a recipient with tokenURI (ex: ipfs://Qm...)
function mintTo(address recipient, string memory tokenURI) public onlyOwner returns (uint256) {
_tokenIds += 1;
uint256 newItemId = _tokenIds;
_mint(recipient, newItemId);
_setTokenURI(newItemId, tokenURI);
return newItemId;
}
/// @notice Total minted so far
function totalMinted() external view returns (uint256) {
return _tokenIds;
}
}
Why ERC721URIStorage
? It gives a convenient _setTokenURI
helper to store token metadata pointer (URI) — good for off-chain metadata. Use ERC-721 standard behavior for ownership and transfers. ([OpenZeppelin Docs][5])
3) Foundry deploy script
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();
new MyCollectible();
vm.stopBroadcast();
}
}
4) Tests (Foundry)
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;
address user = address(0xBEEF);
function setUp() public {
ownerAddr = address(this);
nft = new MyCollectible();
}
function testOwnerMint() public {
string memory uri = "ipfs://test-metadata";
uint256 id = nft.mintTo(user, uri);
assertEq(id, 1);
assertEq(nft.ownerOf(1), user);
assertEq(nft.totalMinted(), 1);
assertEq(nft.tokenURI(1), uri);
}
function testOnlyOwner() public {
// try minting from another address (simulate)
vm.prank(address(0x1234));
vm.expectRevert();
nft.mintTo(address(0x9999), "ipfs://x");
}
}
Run tests:
forge test
5) Build & Local Blockchain (Anvil)
Start Anvil in one terminal:
anvil
# It prints RPC URL (http://127.0.0.1:8545) and pre-funded accounts
Compile & deploy to Anvil (another terminal):
forge build
forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast
forge script
will print the contract address. Copy it for the frontend. You can also use cast
to interact manually. Foundry scripting with Solidity provides a smooth local deploy/test flow. ([getfoundry.sh][3])
6) Metadata & IPFS — using nft.storage
Why store metadata on IPFS? Off-chain metadata keeps gas low and makes metadata content-addressed; pinning via services like nft.storage/Filecoin helps ensure availability. Follow IPFS best practices for metadata structure and immutability. ([docs.ipfs.tech][6])
Create a helper tools/upload-to-nftstorage.js
(Node script) to upload an image and JSON metadata to nft.storage:
// 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";
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));
// multipart upload: first upload the image, then craft metadata JSON pointing to image CID
// For simplicity we'll upload one bundle with metadata
const metadata = {
name,
description: description || "",
image: {
"@type": "Buffer",
data: Array.from(imageData)
}
};
// nft.storage expects a file upload; more robust script would use 'nft.storage' npm package
const resp = await fetch(API, {
method: "POST",
headers: {
Authorization: `Bearer ${key}`,
Accept: "application/json"
},
body: imageData
});
if (!resp.ok) {
console.error("Upload failed", await resp.text());
process.exit(1);
}
const j = await resp.json();
console.log("Upload result:", j);
// Real workflows: use nft.storage client SDK to upload both image and metadata and get CID for JSON
}
main().catch(e => { console.error(e); process.exit(1); });
Better: use the official nft.storage
JS SDK (recommended) for multi-file + metadata uploads. Example quickstart is available on nft.storage docs. ([classic-app.nft.storage][7])
Metadata JSON example (what your tokenURI
should point to):
{
"name": "MyCollectible #1",
"description": "First collectible in the series",
"image": "ipfs://bafybe.../image.png",
"attributes": [
{ "trait_type": "rarity", "value": "common" }
]
}
Upload the image(s) to nft.storage → get ipfs://<CID>/image.png
, then upload the metadata JSON and get its ipfs://<CID>/metadata.json
. Use that metadata URI when minting.
7) Frontend — React + Ethers.js
Create a minimal Vite React app in frontend/
and add ethers@6
.
frontend/src/abi.json
— copy the contract ABI (or use the artifact output from Foundry out/
).
frontend/src/nftService.js
:
import { ethers } from "ethers";
import abi from "./abi.json";
const CONTRACT_ADDRESS = "<PASTE_DEPLOYED_ADDRESS>";
export async function connectWallet() {
if (!window.ethereum) throw new Error("No wallet found");
await window.ethereum.request({ method: "eth_requestAccounts" });
const provider = new ethers.BrowserProvider(window.ethereum);
return provider;
}
export async function mintNFT(tokenURI) {
const provider = await connectWallet();
const signer = await provider.getSigner();
const contract = new ethers.Contract(CONTRACT_ADDRESS, abi, signer);
// call mintTo (onlyOwner in our example) — to test locally add owner as a local Anvil account
const user = await signer.getAddress();
const tx = await contract.mintTo(user, tokenURI);
await tx.wait();
return tx.hash;
}
frontend/src/App.jsx
:
import React, { useState } from "react";
import { mintNFT } from "./nftService";
export default function App() {
const [uri, setUri] = useState("");
const [status, setStatus] = useState("");
const handleMint = async () => {
try {
setStatus("Minting...");
const tx = await mintNFT(uri);
setStatus(`Minted: ${tx}`);
} catch (e) {
setStatus(`Error: ${e.message}`);
}
};
return (
<div style={{ padding: 40 }}>
<h1>My Collectible — Mint</h1>
<input type="text" placeholder="ipfs://..." value={uri} onChange={e => setUri(e.target.value)} style={{ width: '60%' }} />
<button onClick={handleMint}>Mint</button>
<p>{status}</p>
</div>
);
}
Run dev server:
cd frontend
npm install
npm run dev
# open http://localhost:5173
Note: To test minting from the browser on a local Anvil instance:
- Add Anvil RPC (http://127.0.0.1:8545) to MetaMask as a custom network (Chain ID and accounts from Anvil).
- Import one of the Anvil private keys into MetaMask to act as the contract owner (because
mintTo
isonlyOwner
in this example). ([Cyfrin Updraft][8])
8) End-to-end local workflow (commands)
- Start local chain:
anvil
- Deploy contract:
forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast
- Copy deployed address → paste into
frontend/src/nftService.js
. - Start frontend:
cd frontend
npm run dev
- Upload assets to nft.storage, get
ipfs://
metadata URI, paste into frontend and mint.
9) Security & best practices
- Use OpenZeppelin audited implementations for ERC-721 to reduce risks. ([OpenZeppelin Docs][4])
- Keep large media off-chain — store on IPFS/Filecoin and reference by CID. This prevents huge gas costs and leverages content addressing. ([docs.ipfs.tech][6])
- Consider
ReentrancyGuard
if your contract handles ETH transfers and marketplace flows. - Be explicit about immutability: once you upload metadata to IPFS it’s content-addressed; mutability requires separate design (upgradable metadata pointers, on-chain metadata, or mutable gateway mapping). ([docs.ipfs.tech][6])
References (authoritative)
- EIP-721 — Non-Fungible Token Standard (spec). ([Ethereum Improvement Proposals][1])
- OpenZeppelin — ERC-721 docs & implementations. ([OpenZeppelin Docs][4])
- Foundry — scripting with Solidity (deploy & local flow). ([getfoundry.sh][3])
- nft.storage — upload & pin metadata (IPFS + Filecoin). ([nft.storage][2])
- IPFS best practices for NFT data. ([docs.ipfs.tech][6])
Final notes
- If you prefer
public mint
(anyone can mint) removeonlyOwner
and add mint price logic (requiremsg.value == price
) and supply checks. For production consider royalties (ERC-2981), metadata mutability policy, and marketplace integration.
Top comments (0)