
Building a Safe ERC-721 Metadata Resolver Library in Solidity
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
| Scenario | Why a library helps |
|---|---|
| Collection reveal flow | Switch from placeholder metadata to final metadata in one place |
| Special edition NFTs | Override a few token URIs without changing the whole collection |
| Multi-contract deployments | Reuse the same metadata logic across several NFT collections |
| Upgradeable systems | Keep metadata behavior isolated from contract-specific business logic |
Design goals
A practical metadata resolver library should be:
- Deterministic — given the same inputs, it returns the same URI.
- Safe — it should reject invalid token IDs or missing tokens when appropriate.
- Flexible — it should support base URIs, suffixes, and overrides.
- Gas-conscious — it should avoid unnecessary storage reads and string concatenation.
- 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:
- If the token does not exist, revert.
- If a token-specific URI is set, return it.
- If a base URI is set, append the token ID.
- 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
| Strategy | Pros | Cons | Best for |
|---|---|---|---|
| Base URI + token ID | Simple, compact, widely supported | Requires stable off-chain hosting | Standard NFT collections |
| Per-token override | Highly flexible | More storage writes | Special editions, curated drops |
| Fallback URI | Good for placeholders and reveal phases | Less specific than token-level data | Pre-reveal collections |
| Fully on-chain metadata | Immutable and self-contained | Higher gas and complexity | Art 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.
