DEV Community

Cover image for Day 21 — Build Digital Collectibles (ERC-721 NFTs) — Full tutorial (Foundry + React)
Saurav Kumar
Saurav Kumar

Posted on

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 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

Run tests:

forge test
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Compile & deploy to Anvil (another terminal):

forge build
forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast
Enter fullscreen mode Exit fullscreen mode

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); });
Enter fullscreen mode Exit fullscreen mode

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" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Run dev server:

cd frontend
npm install
npm run dev
# open http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  1. Deploy contract:
   forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast
Enter fullscreen mode Exit fullscreen mode
  1. Copy deployed address → paste into frontend/src/nftService.js.
  2. Start frontend:
   cd frontend
   npm run dev
Enter fullscreen mode Exit fullscreen mode
  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.

Top comments (0)