Goal: build a minimal ERC‑721 NFT, attach a tokenURI with metadata (image, name, description), deploy with MetaMask → Ganache, then preview it with a tiny HTML/JS viewer.
In Remix, make MyNFT.sol and paste:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.0.2/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.0.2/contracts/access/Ownable.sol";
import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.0.2/contracts/utils/Counters.sol";
contract MyNFT is ERC721URIStorage, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
constructor(string memory name_, string memory symbol_)
ERC721(name_, symbol_)
Ownable(msg.sender)
{}
function mintTo(address to, string memory uri) public onlyOwner returns (uint256) {
_tokenIds.increment();
uint256 newId = _tokenIds.current();
_mint(to, newId);
_setTokenURI(newId, uri);
return newId;
}
}
ERC721URIStorage
lets us store a per‑token URI string.mintTo(to, uri)
mints to any address and sets its metadata URI.Ownable
restricts minting to the deployer (owner) for classroom control.If the GitHub imports fail, try another stable OZ version (e.g., v5.0.x) or pin libraries via Remix’s Libraries plugin.
An NFT’s tokenURI points to a JSON like:
{
"name": "FIN451 Badge #1",
"description": "Class NFT for ICE 6",
"image": "https://upload.wikimedia.org/wikipedia/commons/3/36/Meta-Logo.png",
"attributes": [
{"trait_type": "Course", "value": "FIN451"},
{"trait_type": "ICE", "value": "6"}
]
}
name
, description
, and an image
URL (any public image URL works for class).Optional: use IPFS (Pinata/web3.storage) and set tokenURI
to ipfs://...CID.... For now, a plain HTTPS URL is fine.
Gas estimation failed? Make sure your selected MetaMask account has Ganache ETH and that Injected Provider shows Localhost.
Save as nft_viewer.html. Paste your contract address and ABI (Remix → Compilation Details → ABI). Enter a tokenId to fetch and render its image.
<!DOCTYPE html>
<html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>NFT Viewer</title></head>
<body>
<button id="connect">Connect MetaMask</button>
<div>Contract: <input id="addr" value="PASTE_CONTRACT_ADDRESS" style="width:360px"/></div>
<textarea id="abi" rows="6" style="width:100%">PASTE_ABI_JSON_ARRAY</textarea>
<div>Token ID: <input id="tid" type="number" value="1" style="width:120px"/> <button id="load">Load</button></div>
<div id="out"></div>
<script src="https://cdn.jsdelivr.net/npm/ethers@6.13.2/dist/ethers.min.js"></script>
<script>
let provider;
document.getElementById('connect').onclick = async () => {
if(!window.ethereum) return alert('MetaMask not found');
provider = new ethers.BrowserProvider(window.ethereum);
await provider.send('eth_requestAccounts', []);
};
document.getElementById('load').onclick = async () => {
const addr = document.getElementById('addr').value.trim();
const abi = JSON.parse(document.getElementById('abi').value.trim());
const tid = document.getElementById('tid').value;
const c = new ethers.Contract(addr, abi, provider || new ethers.BrowserProvider(window.ethereum));
const uri = await c.tokenURI(tid);
const res = await fetch(uri);
const meta = await res.json();
document.getElementById('out').innerHTML = `
${meta.name || ''}
${meta.description || ''}
${JSON.stringify(meta, null, 2)}
`;
};
</script>
</body></html>
Some NFTs use ipfs:// image links. For a quick demo, keep images on plain HTTPS while learning.
mintTo(to, uri)
for tokenId 1 and tokenURI(1)
.tokenURI
is a reachable HTTPS URL returning JSON with an image
field.Next: coming soon.