w3-kitdocs

display-nft-metadata

Fetch and render NFT metadata — name, image, and attributes — from EVM (ERC-721 tokenURI) or Solana (Metaplex metadata PDA).

evmsolana

Dependencies

viem@solana/web3.js
display-nft-metadata/evm.tsx
"use client";

import { usePublicClient } from "wagmi";
import { useState } from "react";

const erc721TokenURIAbi = [
  {
    name: "tokenURI",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "tokenId", type: "uint256" }],
    outputs: [{ name: "", type: "string" }],
  },
] as const;

type NFTMetadata = {
  name?: string;
  description?: string;
  image?: string;
  attributes?: Array<{ trait_type: string; value: string | number }>;
};

function resolveUri(uri: string): string {
  if (uri.startsWith("ipfs://")) return uri.replace("ipfs://", "https://ipfs.io/ipfs/");
  if (uri.startsWith("ar://")) return uri.replace("ar://", "https://arweave.net/");
  return uri;
}

export function DisplayNFTMetadata() {
  const client = usePublicClient();
  const [contractAddress, setContractAddress] = useState("");
  const [tokenId, setTokenId] = useState("");
  const [metadata, setMetadata] = useState<NFTMetadata | null>(null);
  const [tokenUri, setTokenUri] = useState<string | null>(null);
  const [isFetching, setIsFetching] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleFetch = async () => {
    if (!client || !contractAddress || !tokenId) return;
    setIsFetching(true);
    setError(null);
    setMetadata(null);
    setTokenUri(null);

    try {
      // 1. Read tokenURI from the contract
      const uri = await client.readContract({
        address: contractAddress as `0x${string}`,
        abi: erc721TokenURIAbi,
        functionName: "tokenURI",
        args: [BigInt(tokenId)],
      });
      setTokenUri(uri);

      // 2. Handle base64-encoded on-chain metadata
      if (uri.startsWith("data:application/json;base64,")) {
        const json = atob(uri.split(",")[1]);
        setMetadata(JSON.parse(json));
        return;
      }

      // 3. Fetch off-chain metadata (IPFS / Arweave / HTTP)
      const resolved = resolveUri(uri);
      const res = await fetch(resolved);
      const json = await res.json();
      setMetadata(json);
    } catch (e: unknown) {
      setError(e instanceof Error ? e.message : String(e));
    } finally {
      setIsFetching(false);
    }
  };

  return (
    <div>
      <h2>Display NFT Metadata (EVM)</h2>
      <input
        value={contractAddress}
        onChange={(e) => setContractAddress(e.target.value)}
        placeholder="ERC-721 contract address (0x...)"
      />
      <input
        value={tokenId}
        onChange={(e) => setTokenId(e.target.value)}
        placeholder="Token ID"
      />
      <button onClick={handleFetch} disabled={isFetching || !contractAddress || !tokenId}>
        {isFetching ? "Fetching..." : "Fetch Metadata"}
      </button>

      {tokenUri && <p>URI: <code>{tokenUri}</code></p>}

      {metadata && (
        <div>
          <h3>{metadata.name}</h3>
          {metadata.image && (
            <img
              src={resolveUri(metadata.image)}
              alt={metadata.name}
              style={{ maxWidth: "300px", display: "block", margin: "1rem 0" }}
            />
          )}
          {metadata.description && <p>{metadata.description}</p>}
          {metadata.attributes && (
            <ul>
              {metadata.attributes.map((attr, i) => (
                <li key={i}>{attr.trait_type}: {attr.value}</li>
              ))}
            </ul>
          )}
        </div>
      )}
      {error && <p>Error: {error}</p>}
    </div>
  );
}

Display NFT Metadata — Learn

The metadata problem

An NFT on-chain is just a token ID and an ownership record. The art, name, and attributes that make it valuable live off-chain. This separation exists because storing images on a blockchain is prohibitively expensive — storing 1KB of data on Ethereum costs roughly $5–50 depending on gas prices.

The solution: store a URI on-chain that points to a JSON file that describes the NFT.

ERC-721 Metadata JSON standard

The ERC-721 standard includes an optional metadata extension (ERC-721 Metadata) that defines:

function tokenURI(uint256 tokenId) external view returns (string memory);

The URI must return JSON matching this schema:

{
  "title": "Asset Metadata",
  "type": "object",
  "properties": {
    "name": { "type": "string", "description": "Name of the asset" },
    "description": { "type": "string", "description": "Description" },
    "image": { "type": "string", "description": "URI to the image" }
  }
}

Most collections extend this with an attributes array:

{
  "name": "CoolNFT #42",
  "description": "One of 10,000 cool NFTs",
  "image": "ipfs://Qm.../42.png",
  "attributes": [
    { "trait_type": "Background", "value": "Blue" },
    { "trait_type": "Eyes", "value": "Laser" },
    { "trait_type": "Rarity Score", "value": 97, "display_type": "number" }
  ]
}

OpenSea popularized this schema and most NFT platforms follow it.

Metaplex JSON standard (Solana)

Metaplex defines a similar schema with some additions:

{
  "name": "Cool NFT #42",
  "symbol": "COOL",
  "description": "One of 10,000 cool NFTs",
  "seller_fee_basis_points": 500,
  "image": "https://arweave.net/.../42.png",
  "external_url": "https://coolnfts.xyz",
  "attributes": [{ "trait_type": "Background", "value": "Blue" }],
  "collection": {
    "name": "Cool NFTs",
    "family": "Cool"
  },
  "properties": {
    "files": [{ "uri": "https://arweave.net/.../42.png", "type": "image/png" }],
    "category": "image",
    "creators": [{ "address": "CreatorPublicKey...", "share": 100 }]
  }
}

Key additions: seller_fee_basis_points (royalties), properties.creators (provenance), properties.files (all associated files).

How the Metaplex metadata PDA works

The Metaplex metadata account address is deterministically derived:

const [metadataPDA] = PublicKey.findProgramAddressSync(
  [
    Buffer.from("metadata"), // seed 1: literal "metadata"
    METADATA_PROGRAM_ID.toBuffer(), // seed 2: Metaplex program ID
    mintPublicKey.toBuffer(), // seed 3: the NFT's mint address
  ],
  METADATA_PROGRAM_ID,
);

Given any mint address, you can always derive where the metadata lives — no indexer needed.

Metaplex account data layout

The metadata account stores (simplified):

[1]    key (discriminator)
[32]   update_authority
[32]   mint
[4+n]  name (length-prefixed string)
[4+n]  symbol
[4+n]  uri
[2]    seller_fee_basis_points
[1+?]  creators (Option<Vec<Creator>>)
[1]    primary_sale_happened
[1]    is_mutable
...    (additional fields for editions, token standard, etc.)

Resolving URIs

IPFS

// ipfs://QmHash.../file.json → https://ipfs.io/ipfs/QmHash.../file.json
const resolved = uri.replace("ipfs://", "https://ipfs.io/ipfs/");

Multiple gateways exist — use a reliable one or self-host:

  • https://ipfs.io/ipfs/
  • https://cloudflare-ipfs.com/ipfs/
  • https://gateway.pinata.cloud/ipfs/
  • https://<hash>.ipfs.nftstorage.link/

Arweave

// ar://TxId → https://arweave.net/TxId
const resolved = uri.replace("ar://", "https://arweave.net/");

Base64 on-chain data

// data:application/json;base64,eyJuYW1lIj...
if (uri.startsWith("data:application/json;base64,")) {
  const json = atob(uri.split(",")[1]);
  const metadata = JSON.parse(json);
}

On-chain SVGs use data:image/svg+xml;base64,... for the image field.

Rendering considerations

Image security

  • Render images in <img> tags with a referrerPolicy="no-referrer" to prevent tracking
  • For SVG NFTs, render in a sandboxed <iframe> to prevent XSS
  • Consider running images through an image proxy service to avoid mixed content warnings

CORS

IPFS gateways and Arweave support CORS from browsers. Direct RPC nodes typically do not — always fetch metadata from the client side, not server-side functions that might have restrictive CORS handling.

Caching

Metadata is immutable (for reputable collections) — cache aggressively. A one-time fetch and local storage/IndexedDB cache dramatically improves UX for collection galleries.

On-chain vs off-chain metadata

Fully on-chainOff-chain (IPFS/Arweave)Off-chain (HTTP)
PermanencePermanentPermanent (Arweave) / fragile (IPFS)Fragile — server can go down
MutabilityImmutable once setImmutable (content-addressed)Can change anytime
CostVery highLow (store URI only)Low
ExamplesNouns, AutoglyphsMost serious collectionsEarly/low-budget projects

"Rug" risk for metadata: if an NFT project uses HTTP URIs and their server goes down, the metadata disappears. IPFS with pinning and Arweave are the safer choices.