Why a metadata resolver library is useful

ERC-721 metadata is deceptively simple. A contract may expose name(), symbol(), and tokenURI(uint256), but real deployments often need:

  • a base URI that can change over time
  • per-token URI overrides for special editions or revealed metadata
  • safe handling of nonexistent token IDs
  • compatibility with off-chain indexers and marketplaces
  • a clean separation between storage and URI construction

A library is a good fit when multiple contracts share the same metadata rules. Instead of copying URI logic into every NFT contract, you can store the data in a struct and delegate URI resolution to a library function.

Typical use cases

ScenarioWhy a library helps
Collection reveal flowSwitch from placeholder metadata to final metadata in one place
Special edition NFTsOverride a few token URIs without changing the whole collection
Multi-contract deploymentsReuse the same metadata logic across several NFT collections
Upgradeable systemsKeep metadata behavior isolated from contract-specific business logic

Design goals

A practical metadata resolver library should be:

  1. Deterministic — given the same inputs, it returns the same URI.
  2. Safe — it should reject invalid token IDs or missing tokens when appropriate.
  3. Flexible — it should support base URIs, suffixes, and overrides.
  4. Gas-conscious — it should avoid unnecessary storage reads and string concatenation.
  5. Easy to integrate — it should work with standard ERC-721 contracts.

We will implement a library that supports three resolution modes:

  • Base URI + token ID: https://api.example.com/metadata/123
  • Per-token override: https://cdn.example.com/special/123.json
  • Fallback URI: used when no base URI is configured

Library data model

We need a storage layout that can be embedded into a contract. The library will operate on a struct containing:

  • a base URI
  • a fallback URI
  • a mapping of token IDs to override URIs
  • a mapping of existing token IDs

The existence mapping is important because tokenURI() should usually revert for nonexistent tokens.

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

library MetadataResolver {
    struct Data {
        string baseURI;
        string fallbackURI;
        mapping(uint256 => string) tokenURIs;
        mapping(uint256 => bool) exists;
    }

    error NonexistentToken(uint256 tokenId);
    error EmptyURI();

This structure keeps the library generic. The consuming contract decides how tokens are minted and burned; the library only resolves metadata.


Core resolution logic

The main function should return the most specific URI available:

  1. If the token does not exist, revert.
  2. If a token-specific URI is set, return it.
  3. If a base URI is set, append the token ID.
  4. Otherwise, return the fallback URI.

For string concatenation, Solidity does not provide a native efficient formatter. We can use abi.encodePacked for simple concatenation and a small helper to convert uint256 to decimal string.

    function tokenURI(Data storage self, uint256 tokenId)
        internal
        view
        returns (string memory)
    {
        if (!self.exists[tokenId]) {
            revert NonexistentToken(tokenId);
        }

        string memory custom = self.tokenURIs[tokenId];
        if (bytes(custom).length != 0) {
            return custom;
        }

        if (bytes(self.baseURI).length != 0) {
            return string(abi.encodePacked(self.baseURI, _toString(tokenId)));
        }

        return self.fallbackURI;
    }

This pattern is easy to reason about and mirrors how many production NFT contracts behave.


Setting and clearing URIs

A metadata library is only useful if it also provides safe mutation helpers. We will add functions for:

  • setting the base URI
  • setting the fallback URI
  • setting a token-specific URI
  • clearing a token-specific URI
  • marking a token as existing or burned
    function setBaseURI(Data storage self, string memory newBaseURI) internal {
        self.baseURI = newBaseURI;
    }

    function setFallbackURI(Data storage self, string memory newFallbackURI) internal {
        self.fallbackURI = newFallbackURI;
    }

    function setTokenURI(
        Data storage self,
        uint256 tokenId,
        string memory newURI
    ) internal {
        if (!self.exists[tokenId]) {
            revert NonexistentToken(tokenId);
        }
        if (bytes(newURI).length == 0) {
            revert EmptyURI();
        }
        self.tokenURIs[tokenId] = newURI;
    }

    function clearTokenURI(Data storage self, uint256 tokenId) internal {
        if (!self.exists[tokenId]) {
            revert NonexistentToken(tokenId);
        }
        delete self.tokenURIs[tokenId];
    }

    function setExists(Data storage self, uint256 tokenId, bool value) internal {
        self.exists[tokenId] = value;
    }

Notice that setTokenURI rejects empty strings. This avoids accidental writes that would later be interpreted as “no override,” which can create confusing behavior during reveal workflows.


String conversion helper

To build baseURI + tokenId, we need a decimal conversion helper. Solidity does not include one in the standard library, so the library should provide a compact implementation.

    function _toString(uint256 value) private pure returns (string memory) {
        if (value == 0) {
            return "0";
        }

        uint256 temp = value;
        uint256 digits;
        while (temp != 0) {
            digits++;
            temp /= 10;
        }

        bytes memory buffer = new bytes(digits);
        while (value != 0) {
            digits -= 1;
            buffer[digits] = bytes1(uint8(48 + (value % 10)));
            value /= 10;
        }

        return string(buffer);
    }
}

This implementation is straightforward and gas-efficient enough for typical NFT metadata resolution, where the function is called off-chain through eth_call rather than on-chain in a state-changing transaction.


Full library example

Here is the complete library in one piece:

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

library MetadataResolver {
    struct Data {
        string baseURI;
        string fallbackURI;
        mapping(uint256 => string) tokenURIs;
        mapping(uint256 => bool) exists;
    }

    error NonexistentToken(uint256 tokenId);
    error EmptyURI();

    function tokenURI(Data storage self, uint256 tokenId)
        internal
        view
        returns (string memory)
    {
        if (!self.exists[tokenId]) {
            revert NonexistentToken(tokenId);
        }

        string memory custom = self.tokenURIs[tokenId];
        if (bytes(custom).length != 0) {
            return custom;
        }

        if (bytes(self.baseURI).length != 0) {
            return string(abi.encodePacked(self.baseURI, _toString(tokenId)));
        }

        return self.fallbackURI;
    }

    function setBaseURI(Data storage self, string memory newBaseURI) internal {
        self.baseURI = newBaseURI;
    }

    function setFallbackURI(Data storage self, string memory newFallbackURI) internal {
        self.fallbackURI = newFallbackURI;
    }

    function setTokenURI(
        Data storage self,
        uint256 tokenId,
        string memory newURI
    ) internal {
        if (!self.exists[tokenId]) {
            revert NonexistentToken(tokenId);
        }
        if (bytes(newURI).length == 0) {
            revert EmptyURI();
        }
        self.tokenURIs[tokenId] = newURI;
    }

    function clearTokenURI(Data storage self, uint256 tokenId) internal {
        if (!self.exists[tokenId]) {
            revert NonexistentToken(tokenId);
        }
        delete self.tokenURIs[tokenId];
    }

    function setExists(Data storage self, uint256 tokenId, bool value) internal {
        self.exists[tokenId] = value;
    }

    function _toString(uint256 value) private pure returns (string memory) {
        if (value == 0) {
            return "0";
        }

        uint256 temp = value;
        uint256 digits;
        while (temp != 0) {
            digits++;
            temp /= 10;
        }

        bytes memory buffer = new bytes(digits);
        while (value != 0) {
            digits -= 1;
            buffer[digits] = bytes1(uint8(48 + (value % 10)));
            value /= 10;
        }

        return string(buffer);
    }
}

Integrating the library into an ERC-721 contract

The library becomes useful when embedded in a token contract. Below is a minimal example showing how to use it with a custom ERC-721 implementation.

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

import "./MetadataResolver.sol";

contract ExampleNFT {
    using MetadataResolver for MetadataResolver.Data;

    MetadataResolver.Data private metadata;

    address public owner;
    uint256 public totalSupply;

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
        metadata.setFallbackURI("ipfs://QmPlaceholder/");
    }

    function mint(address to) external onlyOwner returns (uint256 tokenId) {
        tokenId = ++totalSupply;
        metadata.setExists(tokenId, true);
    }

    function burn(uint256 tokenId) external onlyOwner {
        metadata.setExists(tokenId, false);
        metadata.clearTokenURI(tokenId);
    }

    function setBaseURI(string calldata newBaseURI) external onlyOwner {
        metadata.setBaseURI(newBaseURI);
    }

    function setTokenURI(uint256 tokenId, string calldata newURI) external onlyOwner {
        metadata.setTokenURI(tokenId, newURI);
    }

    function tokenURI(uint256 tokenId) external view returns (string memory) {
        return metadata.tokenURI(tokenId);
    }
}

This example is intentionally minimal. In a real ERC-721 contract, you would also track ownership, approvals, and transfer logic. The metadata library remains independent of those concerns.


Best practices for production use

1. Separate existence from ownership

Do not infer token existence from ownership mappings unless your token model guarantees a one-to-one relationship. A burned token may still have historical ownership data, and a lazy-minted token may exist before assignment. Keep a dedicated existence flag or use your token accounting model carefully.

2. Prefer explicit override semantics

If a token-specific URI is set, it should clearly override the base URI. Avoid ambiguous rules like “use override only if base URI is empty,” because they make reveal behavior harder to audit.

3. Be careful with empty strings

An empty string can mean either “unset” or “intentionally blank.” In metadata systems, that ambiguity is usually harmful. The library above treats empty custom URIs as invalid input.

4. Keep on-chain metadata resolution simple

If your metadata is fully on-chain, the resolver should not become a general-purpose string engine. For complex JSON generation, consider a separate renderer contract or a fully on-chain metadata architecture.

5. Emit events for off-chain indexing

The library itself does not emit events, but the consuming contract should emit events when base URIs or token URIs change. Indexers and marketplaces rely on these signals to refresh cached metadata.


Comparison of resolution strategies

StrategyProsConsBest for
Base URI + token IDSimple, compact, widely supportedRequires stable off-chain hostingStandard NFT collections
Per-token overrideHighly flexibleMore storage writesSpecial editions, curated drops
Fallback URIGood for placeholders and reveal phasesLess specific than token-level dataPre-reveal collections
Fully on-chain metadataImmutable and self-containedHigher gas and complexityArt NFTs, long-term archival use

In practice, many collections combine base URI resolution with selective overrides for rare tokens or special cases.


Common pitfalls

Forgetting to validate token existence

If tokenURI() returns a value for nonexistent tokens, external tools may index phantom assets. Always validate existence before resolving metadata.

Storing large strings unnecessarily

Token-specific URIs can be expensive if overused. If most tokens share the same pattern, prefer a base URI and only override exceptions.

Mutating metadata without access control

Metadata updates are sensitive. A compromised or misconfigured setter can break collection trust. Restrict all mutation functions with strong access control.

Mixing reveal logic into minting logic

Reveal workflows are easier to audit when metadata updates are separate from minting. Minting should create tokens; metadata functions should manage URIs.


When to choose a library over inheritance

A library is ideal when the metadata behavior is a reusable utility. Inheritance is better when the metadata rules are part of a larger token framework.

Choose a library if:

  • multiple contracts need the same URI resolution logic
  • you want to keep storage and behavior modular
  • you prefer explicit function calls over inherited base classes

Choose inheritance if:

  • the contract family shares many behaviors beyond metadata
  • you want to override hooks like _baseURI() in a framework such as OpenZeppelin ERC-721

For many projects, a library offers a cleaner boundary because it isolates metadata policy from token mechanics.


Learn more with useful resources