Day 21 — Build Digital Collectibles (ERC-721 NFTs) — Full tutorial (Foundry + React)

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…


This content originally appeared on DEV Community and was authored by Saurav Kumar

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 stores tokenURI.
  • 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 is onlyOwner in this example). ([Cyfrin Updraft][8])

8) End-to-end local workflow (commands)

  1. Start local chain:
   anvil
  1. Deploy contract:
   forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast
  1. Copy deployed address → paste into frontend/src/nftService.js.
  2. Start frontend:
   cd frontend
   npm run dev
  1. 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) remove onlyOwner and add mint price logic (require msg.value == price) and supply checks. For production consider royalties (ERC-2981), metadata mutability policy, and marketplace integration.


This content originally appeared on DEV Community and was authored by Saurav Kumar


Print Share Comment Cite Upload Translate Updates
APA

Saurav Kumar | Sciencx (2025-10-23T03:38:49+00:00) Day 21 — Build Digital Collectibles (ERC-721 NFTs) — Full tutorial (Foundry + React). Retrieved from https://www.scien.cx/2025/10/23/day-21-build-digital-collectibles-erc-721-nfts-full-tutorial-foundry-react/

MLA
" » Day 21 — Build Digital Collectibles (ERC-721 NFTs) — Full tutorial (Foundry + React)." Saurav Kumar | Sciencx - Thursday October 23, 2025, https://www.scien.cx/2025/10/23/day-21-build-digital-collectibles-erc-721-nfts-full-tutorial-foundry-react/
HARVARD
Saurav Kumar | Sciencx Thursday October 23, 2025 » Day 21 — Build Digital Collectibles (ERC-721 NFTs) — Full tutorial (Foundry + React)., viewed ,<https://www.scien.cx/2025/10/23/day-21-build-digital-collectibles-erc-721-nfts-full-tutorial-foundry-react/>
VANCOUVER
Saurav Kumar | Sciencx - » Day 21 — Build Digital Collectibles (ERC-721 NFTs) — Full tutorial (Foundry + React). [Internet]. [Accessed ]. Available from: https://www.scien.cx/2025/10/23/day-21-build-digital-collectibles-erc-721-nfts-full-tutorial-foundry-react/
CHICAGO
" » Day 21 — Build Digital Collectibles (ERC-721 NFTs) — Full tutorial (Foundry + React)." Saurav Kumar | Sciencx - Accessed . https://www.scien.cx/2025/10/23/day-21-build-digital-collectibles-erc-721-nfts-full-tutorial-foundry-react/
IEEE
" » Day 21 — Build Digital Collectibles (ERC-721 NFTs) — Full tutorial (Foundry + React)." Saurav Kumar | Sciencx [Online]. Available: https://www.scien.cx/2025/10/23/day-21-build-digital-collectibles-erc-721-nfts-full-tutorial-foundry-react/. [Accessed: ]
rf:citation
» Day 21 — Build Digital Collectibles (ERC-721 NFTs) — Full tutorial (Foundry + React) | Saurav Kumar | Sciencx | https://www.scien.cx/2025/10/23/day-21-build-digital-collectibles-erc-721-nfts-full-tutorial-foundry-react/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.