Development of NFT Metadata (On-Chain/Off-Chain)
tokenURI() returns a string — URL or base64-encoded JSON. Behind this simplicity lies an architectural decision that will determine the collection's fate for years. Metadata on centralized IPFS gateway is not "decentralized metadata", it's a link to Pinata server, which can disappear. NFT with metadata on contract (on-chain) will outlive any hosting.
On-Chain vs. Off-Chain: Real Trade-Offs
Fully On-Chain
Metadata stored right in smart contract. tokenURI() generates JSON and SVG at runtime via string concatenation:
function tokenURI(uint256 tokenId) public view override returns (string memory) {
string memory json = Base64.encode(bytes(string(abi.encodePacked(
'{"name":"Token #', Strings.toString(tokenId),
'","description":"On-chain NFT","image":"data:image/svg+xml;base64,',
Base64.encode(bytes(_generateSVG(tokenId))),
'"}'
))));
return string(abi.encodePacked("data:application/json;base64,", json));
}
Advantage: complete permanence, no dependency on external services. Disadvantage: gas on deploy grows with SVG size. For simple generative collection (Loot, Nouns-style) this works. For photos — no.
Storing attributes in storage: mapping tokenId → struct with trait values. Each attribute — uint8 or bytes32 for slot economy. uint8 attributes packed 32 per storage slot.
IPFS Off-Chain
Standard approach for most collections. Metadata uploaded to IPFS, tokenURI() returns ipfs://CID/tokenId.json. Critical requirement: don't use HTTP gateway in URI.
Correct: ipfs://QmHash/1.json
Incorrect: https://ipfs.io/ipfs/QmHash/1.json
The second is a link to specific HTTP server. It can disappear. The first is content address, works with any IPFS gateway.
For pinning — Pinata + Web3.Storage as backup. For most important collections — Filecoin via NFT.Storage for long-term storage with cryptographic guarantee.
Reveal Mechanism
Pre-reveal: all tokens show placeholder metadata. Post-reveal: real metadata revealed. Naive implementation — owner just changes baseURI. This is centralized and trust-requiring.
Commit-reveal scheme on VRF: before mint owner commits seed hash, after mint is complete — publishes seed and calls Chainlink VRF for random offset. Metadata shuffled deterministically via (tokenId + offset) % totalSupply. Nobody can know beforehand which traits go to which token.
function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
revealOffset = randomWords[0] % maxSupply;
revealed = true;
}
function tokenURI(uint256 tokenId) public view override returns (string memory) {
require(revealed, "Not revealed yet");
uint256 metadataId = (tokenId + revealOffset) % maxSupply;
return string(abi.encodePacked(baseURI, metadataId.toString(), ".json"));
}
JSON Metadata Structure
OpenSea ERC-721 metadata standard:
{
"name": "Token #1",
"description": "Description text",
"image": "ipfs://CID/1.png",
"external_url": "https://project.xyz/token/1",
"attributes": [
{"trait_type": "Background", "value": "Blue"},
{"trait_type": "Rarity", "value": "Legendary", "display_type": "boost_percentage", "max_value": 100}
]
}
display_type controls OpenSea display. Numeric attributes: "number" (just number), "boost_percentage" (progress bar), "boost_number" (modifier), "date" (unix timestamp → date).
For ERC-1155 structure is similar, but tokenURI takes uint256 id and can use {id} placeholder in URI.
Tools and Process
Generating metadata for large collection — off-chain TypeScript script: loads layers, generates combinations with rarity weights, creates JSON and images, batch uploads to IPFS via Pinata API. Then IPFS CID is fixed in contract.
For on-chain SVG — TypeScript script generates Solidity libraries with string constants for each trait. Contract size checked: 24KB limit.
Timeline Reference
IPFS off-chain metadata with reveal mechanism — 2-3 days. On-chain SVG generation for generative collection — 3-5 days depending on art complexity.







