NFT Metadata & IPFS

Difficulty: Intermediate

NFT metadata is the information that gives a non-fungible token its identity - the name, description, image, attributes, and any other properties that make it unique. While the token itself (the ownership record) lives on-chain as part of an ERC-721 or ERC-1155 contract, the metadata and associated media files are typically stored off-chain due to the prohibitive cost of on-chain storage. The standard metadata JSON schema, established by OpenSea and widely adopted, includes fields like name, description, image, external_url, and an attributes array containing trait_type/value pairs. The tokenURI function in the smart contract returns a URI pointing to this JSON file, which marketplaces and wallets use to display the NFT.

The InterPlanetary File System (IPFS) is a decentralized, peer-to-peer storage network that has become the de facto standard for NFT metadata storage. Unlike HTTP URLs that point to a specific server location (which can go offline or change content), IPFS uses content addressing - each file is identified by its Content Identifier (CID), which is a cryptographic hash of the file's contents. This means the same content always produces the same CID, and any node on the IPFS network can serve the file. If someone modifies the file, the CID changes, making IPFS inherently tamper-proof. An IPFS URI looks like ipfs://QmX4zdT8vnKBxyz.../metadata.json, and it can be accessed through any IPFS gateway like https://ipfs.io/ipfs/QmX4zdT8vnKBxyz.../metadata.json.

Pinning services like Pinata, NFT.Storage, and Infura ensure that your IPFS content remains available. While any IPFS node can host content, if no node is actively serving (pinning) a file, it may eventually be garbage-collected and become unavailable. Pinning services guarantee that at least one node is always serving your content. Pinata provides a simple API for uploading files and directories to IPFS, managing pins, and generating gateways. NFT.Storage, backed by Protocol Labs, offers free storage specifically for NFT data using a combination of IPFS and Filecoin for long-term persistence. For production NFT projects, using multiple pinning services provides redundancy.

On-chain metadata is an alternative approach where all metadata is stored directly in the smart contract. This is typically done by generating a base64-encoded JSON string in the tokenURI function, often using SVG for the image. Projects like Loot, Nouns, and Autoglyphs famously use fully on-chain metadata, meaning the NFT is completely self-contained and will exist as long as the blockchain exists. The tradeoff is significantly higher gas costs for minting and storage, and limited media complexity (no high-resolution images, audio, or video). A hybrid approach is also common: store essential metadata on-chain (name, key attributes) while referencing IPFS for media files, providing a balance of permanence and richness.

The metadata JSON schema has evolved to support rich features. Beyond the basic name/description/image, the attributes array enables filterable traits that marketplaces display. The animation_url field supports video, audio, 3D models (GLB/GLTF), and interactive HTML. Properties like background_color and external_url provide additional customization. When designing a metadata schema for a collection, consider which attributes should be filterable (use the attributes array with trait_type/value), which should be display-only, and whether any attributes might change over time (requiring mutable metadata, which IPFS doesn't natively support - you'd need to update the tokenURI to point to new content).

Code examples

ERC-721 with IPFS Metadata

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract NFTWithIPFS is ERC721, Ownable {
    using Strings for uint256;

    string public baseURI;
    string public baseExtension = ".json";
    uint256 public totalSupply;
    uint256 public constant MAX_SUPPLY = 10000;
    bool public revealed;
    string public notRevealedURI;

    constructor(
        string memory _initBaseURI,
        string memory _notRevealedURI
    ) ERC721("MyNFTCollection", "MNFT") Ownable(msg.sender) {
        baseURI = _initBaseURI;
        notRevealedURI = _notRevealedURI;
    }

    function mint(uint256 quantity) external payable {
        require(totalSupply + quantity <= MAX_SUPPLY, "Exceeds max supply");
        for (uint256 i = 0; i < quantity; i++) {
            _safeMint(msg.sender, totalSupply);
            totalSupply++;
        }
    }

    function tokenURI(
        uint256 tokenId
    ) public view override returns (string memory) {
        require(tokenId < totalSupply, "Token does not exist");

        if (!revealed) {
            return notRevealedURI;
        }

        return string(
            abi.encodePacked(baseURI, tokenId.toString(), baseExtension)
        );
    }

    function reveal(string memory _newBaseURI) external onlyOwner {
        revealed = true;
        baseURI = _newBaseURI;
    }

    function setBaseURI(string memory _newBaseURI) external onlyOwner {
        baseURI = _newBaseURI;
    }
}

This contract implements a standard NFT collection with IPFS metadata. Before reveal, all tokens return the same placeholder metadata. After reveal, each token returns its unique metadata from IPFS. The tokenURI concatenates the base URI with the token ID: ipfs://QmBase.../42.json.

Fully On-Chain SVG NFT

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract OnChainNFT is ERC721 {
    using Strings for uint256;
    using Strings for uint8;

    uint256 public totalSupply;

    struct TokenData {
        string name;
        uint8 level;
        uint8 hue;
    }

    mapping(uint256 => TokenData) public tokenData;

    constructor() ERC721("OnChainNFT", "OCNFT") {}

    function mint(string memory name, uint8 level) external {
        uint256 tokenId = totalSupply++;
        uint8 hue = uint8(uint256(keccak256(abi.encodePacked(
            name, level, block.timestamp, msg.sender
        ))) % 360);

        tokenData[tokenId] = TokenData(name, level, hue);
        _safeMint(msg.sender, tokenId);
    }

    function tokenURI(
        uint256 tokenId
    ) public view override returns (string memory) {
        require(tokenId < totalSupply, "Nonexistent token");
        TokenData memory data = tokenData[tokenId];

        string memory svg = string(abi.encodePacked(
            '<svg xmlns="http://www.w3.org/2000/svg" width="350" height="350">',
            '<rect width="350" height="350" fill="hsl(',
            data.hue.toString(),
            ', 70%, 20%)"/>',
            '<text x="175" y="120" text-anchor="middle" ',
            'font-size="24" fill="white" font-family="monospace">',
            data.name,
            '</text>',
            '<text x="175" y="200" text-anchor="middle" ',
            'font-size="64" fill="hsl(',
            data.hue.toString(),
            ', 80%, 60%)">',
            'Lv.',
            data.level.toString(),
            '</text>',
            '<rect x="50" y="260" width="250" height="20" rx="10" ',
            'fill="rgba(255,255,255,0.2)"/>',
            '<rect x="50" y="260" width="',
            ((uint256(data.level) * 250) / 100).toString(),
            '" height="20" rx="10" fill="hsl(',
            data.hue.toString(),
            ', 80%, 60%)"/>',
            '</svg>'
        ));

        string memory json = string(abi.encodePacked(
            '{"name": "', data.name,
            ' #', tokenId.toString(),
            '", "description": "A fully on-chain NFT with dynamic SVG art.",',
            '"image": "data:image/svg+xml;base64,',
            Base64.encode(bytes(svg)),
            '", "attributes": [{"trait_type": "Level", "value": ',
            data.level.toString(),
            '}, {"trait_type": "Hue", "value": ',
            data.hue.toString(),
            '}]}'
        ));

        return string(abi.encodePacked(
            "data:application/json;base64,",
            Base64.encode(bytes(json))
        ));
    }
}

This contract generates both the SVG image and JSON metadata entirely on-chain using base64 encoding. The tokenURI returns a data URI containing the full metadata JSON with an embedded SVG image. No external storage needed - the NFT is fully self-contained and permanent.

Uploading Metadata to IPFS with Pinata

// JavaScript (Node.js): Upload NFT metadata to IPFS via Pinata
const axios = require('axios');
const FormData = require('form-data');
const fs = require('fs');

const PINATA_API_KEY = process.env.PINATA_API_KEY;
const PINATA_SECRET = process.env.PINATA_SECRET;

async function uploadImage(filePath) {
  const formData = new FormData();
  formData.append('file', fs.createReadStream(filePath));
  formData.append('pinataOptions', JSON.stringify({ cidVersion: 1 }));

  const response = await axios.post(
    'https://api.pinata.cloud/pinning/pinFileToIPFS',
    formData,
    {
      headers: {
        ...formData.getHeaders(),
        pinata_api_key: PINATA_API_KEY,
        pinata_secret_api_key: PINATA_SECRET,
      },
    }
  );

  return `ipfs://${response.data.IpfsHash}`;
}

async function uploadMetadata(tokenId, imageURI) {
  const metadata = {
    name: `Cool NFT #${tokenId}`,
    description: 'A unique digital collectible with verifiable ownership on Ethereum.',
    image: imageURI,
    external_url: `https://mycollection.xyz/token/${tokenId}`,
    attributes: [
      { trait_type: 'Background', value: 'Blue' },
      { trait_type: 'Rarity', value: 'Legendary' },
      { trait_type: 'Power Level', display_type: 'number', value: 95 },
      { trait_type: 'Generation', display_type: 'number', value: 1 },
      { trait_type: 'Birthday', display_type: 'date', value: 1672531200 },
    ],
  };

  const response = await axios.post(
    'https://api.pinata.cloud/pinning/pinJSONToIPFS',
    {
      pinataContent: metadata,
      pinataMetadata: { name: `metadata-${tokenId}.json` },
      pinataOptions: { cidVersion: 1 },
    },
    {
      headers: {
        'Content-Type': 'application/json',
        pinata_api_key: PINATA_API_KEY,
        pinata_secret_api_key: PINATA_SECRET,
      },
    }
  );

  return `ipfs://${response.data.IpfsHash}`;
}

async function uploadCollectionDirectory(metadataArray) {
  const formData = new FormData();

  for (let i = 0; i < metadataArray.length; i++) {
    const buffer = Buffer.from(JSON.stringify(metadataArray[i], null, 2));
    formData.append('file', buffer, {
      filename: `metadata/${i}.json`,
      contentType: 'application/json',
    });
  }

  formData.append('pinataOptions', JSON.stringify({ cidVersion: 1 }));

  const response = await axios.post(
    'https://api.pinata.cloud/pinning/pinFileToIPFS',
    formData,
    {
      headers: {
        ...formData.getHeaders(),
        pinata_api_key: PINATA_API_KEY,
        pinata_secret_api_key: PINATA_SECRET,
      },
      maxBodyLength: Infinity,
    }
  );

  return `ipfs://${response.data.IpfsHash}/metadata/`;
}

async function main() {
  const imageURI = await uploadImage('./images/nft-0.png');
  console.log('Image uploaded:', imageURI);

  const metadataURI = await uploadMetadata(0, imageURI);
  console.log('Metadata uploaded:', metadataURI);
}

This script demonstrates the full IPFS upload workflow for NFTs using Pinata. It uploads the image first, gets the IPFS CID, then creates and uploads the metadata JSON referencing the image. The uploadCollectionDirectory function batch-uploads entire collections, producing a base URI that works with the tokenURI pattern baseURI + tokenId + .json.

Key points

Concepts covered

NFT Metadata, IPFS, Content Addressing, Pinata, Token URI, On-chain vs Off-chain