
Building a Safe ERC721 Metadata Reveal Pattern in Solidity
What the reveal pattern solves
An ERC721 token typically exposes metadata through tokenURI(tokenId). Before reveal, many projects want every token to point to the same placeholder JSON. After reveal, each token should resolve to its final metadata file.
A robust reveal pattern should:
- return a placeholder URI until reveal is enabled
- switch to final metadata without changing token ownership
- avoid per-token storage for URI data when possible
- prevent accidental or unauthorized reveal
- remain compatible with marketplaces and wallets
A common mistake is storing a full URI for every token. That works, but it increases gas costs and makes reveal logic harder to manage. A better approach is to use a base URI plus a token ID suffix after reveal.
Design goals
For this example, the contract will support:
- ERC721 minting
- a placeholder metadata URI before reveal
- a base URI after reveal
- an owner-only reveal action
- optional one-way reveal, so metadata cannot be hidden again
This pattern is especially useful for:
- NFT collections with delayed art release
- game items whose final stats are assigned later
- membership passes that initially show generic content
- launch phases where metadata should not be visible too early
Core implementation
Below is a compact but production-oriented contract using OpenZeppelin libraries.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract RevealedCollectible is ERC721, Ownable {
uint256 private _nextTokenId = 1;
// Placeholder shown before reveal
string private _placeholderURI;
// Base URI used after reveal
string private _baseTokenURI;
// Reveal flag
bool public revealed;
constructor(
string memory name_,
string memory symbol_,
string memory placeholderURI_,
string memory baseTokenURI_
) ERC721(name_, symbol_) Ownable(msg.sender) {
_placeholderURI = placeholderURI_;
_baseTokenURI = baseTokenURI_;
}
function mint(address to) external onlyOwner returns (uint256) {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
return tokenId;
}
function reveal() external onlyOwner {
revealed = true;
}
function setPlaceholderURI(string calldata newPlaceholderURI) external onlyOwner {
require(!revealed, "Already revealed");
_placeholderURI = newPlaceholderURI;
}
function setBaseURI(string calldata newBaseURI) external onlyOwner {
require(!revealed, "Already revealed");
_baseTokenURI = newBaseURI;
}
function tokenURI(uint256 tokenId) public view override returns (string memory) {
_requireOwned(tokenId);
if (!revealed) {
return _placeholderURI;
}
return string.concat(_baseTokenURI, _toString(tokenId), ".json");
}
}This contract keeps the reveal logic straightforward: before reveal, every token returns the same placeholder URI; after reveal, each token uses the base URI plus its token ID.
How the pattern works
Before reveal
When revealed == false, tokenURI() returns _placeholderURI for every minted token. This URI usually points to a JSON file such as:
{
"name": "Mystery Collectible",
"description": "Metadata will be revealed soon.",
"image": "ipfs://.../placeholder.png"
}This is useful because marketplaces can still display a valid metadata object, even though the final art is hidden.
After reveal
Once the owner calls reveal(), the contract starts returning:
baseURI + tokenId + ".json"For example:
ipfs://QmBase/1.jsonipfs://QmBase/2.jsonipfs://QmBase/3.json
This approach is efficient because the contract does not need to store a URI for each token.
Why this approach is safer than per-token URI storage
A per-token mapping such as mapping(uint256 => string) is flexible, but it has drawbacks:
| Approach | Pros | Cons |
|---|---|---|
| Per-token URI mapping | Maximum flexibility | Higher storage cost, more complexity |
| Base URI + token ID | Simple, gas-efficient, easy to audit | Requires consistent off-chain file naming |
| Single placeholder then base URI | Good for reveal workflows | Needs a clear reveal policy |
For most collections, base URI plus token ID is the best balance of simplicity and cost.
Best practices for reveal control
1. Make reveal one-way if possible
If metadata is already public, allowing the owner to hide it again can confuse users and marketplaces. A one-way reveal is easier to reason about and reduces trust assumptions.
In the example above, revealed can only move from false to true.
2. Lock URI changes after reveal
If the base URI can be changed after reveal, the owner could redirect metadata to a different server or IPFS folder. That may be acceptable in some projects, but it should be intentional.
A stricter version would freeze the base URI once revealed:
function reveal() external onlyOwner {
revealed = true;
}And remove any post-reveal URI setters. If you need flexibility, document it clearly.
3. Use immutable or well-controlled ownership
The reveal switch is a privileged action. If the owner key is compromised, metadata can be manipulated. Consider:
- a multisig owner
- a timelocked admin process
- a dedicated deployment wallet with limited exposure
4. Validate token existence in tokenURI()
Always check that the token exists before returning metadata. In OpenZeppelin ERC721, _requireOwned(tokenId) ensures the token was minted and has not been burned.
5. Keep off-chain metadata deterministic
If you use tokenId.json, make sure your storage layout is stable and predictable. A common convention is:
metadata/
1.json
2.json
3.jsonThis makes it easy to generate files and verify them before deployment.
Handling placeholder metadata correctly
A placeholder should still be a valid ERC721 metadata object. Marketplaces expect fields like name, description, and image. If the placeholder JSON is malformed, some platforms may fail to display the token properly.
A good placeholder JSON might look like this:
{
"name": "Revealed Collectible #1",
"description": "The final metadata has not been revealed yet.",
"image": "ipfs://QmPlaceholder/hidden.png",
"attributes": [
{
"trait_type": "Status",
"value": "Hidden"
}
]
}This keeps the token visible while clearly signaling that final metadata is pending.
Common implementation mistakes
Returning an empty string before reveal
An empty tokenURI() result is not a good placeholder. Some clients treat it as missing metadata, which can lead to inconsistent display behavior.
Using tokenId without existence checks
If tokenURI() does not verify ownership or mint status, callers may query nonexistent tokens and receive misleading results.
Forgetting to freeze metadata policy
If users expect a permanent reveal, but the contract allows the owner to change the base URI later, trust can be damaged. Decide early whether metadata is mutable or immutable.
Hardcoding file paths incorrectly
If your contract returns baseURI + tokenId, your off-chain files must match the same format exactly. For example, if the contract appends .json, your storage must use that extension too.
Extending the pattern
You can adapt this design in several useful ways.
Reveal by timestamp
Instead of manual reveal, you can reveal automatically after a specific block timestamp:
uint256 public revealTime;
function isRevealed() public view returns (bool) {
return block.timestamp >= revealTime;
}This is useful when the reveal date is announced in advance. However, manual reveal is often simpler and more flexible.
Reveal in phases
For larger collections, you may want multiple stages:
- phase 1: placeholder
- phase 2: partial reveal
- phase 3: final reveal
That can be implemented with a revealStage variable and multiple base URIs. Keep the logic explicit so users know which stage is active.
Use encrypted metadata off-chain
Some projects store encrypted metadata and publish the decryption key at reveal time. This is more complex and usually unnecessary unless you need stronger secrecy before launch.
Testing the reveal behavior
A reveal contract should be tested for both pre- and post-reveal behavior. At minimum, cover these cases:
tokenURI()reverts for nonexistent tokenstokenURI()returns placeholder before revealtokenURI()returns final URI after reveal- only the owner can call
reveal() - URI setters are blocked after reveal, if you enforce freezing
A simple test sequence might look like this:
- Deploy contract with placeholder and base URI.
- Mint token
1. - Confirm
tokenURI(1)returns placeholder. - Call
reveal(). - Confirm
tokenURI(1)returnsbaseURI + "1.json".
This verifies the core behavior without relying on marketplace-specific assumptions.
Deployment and operational checklist
Before deploying, confirm the following:
- placeholder metadata is valid JSON
- final metadata files are uploaded and accessible
- token IDs match the off-chain file names
- the owner account is secure
- reveal policy is documented for users
- if using IPFS, the content is pinned and immutable
If you are using a centralized server instead of IPFS, make sure you understand the trust trade-offs. A centralized endpoint can be changed or taken offline, which may break metadata resolution.
When to use this pattern
Use a reveal pattern when:
- you want to hide final artwork until a specific moment
- you need a simple, auditable metadata switch
- your collection uses predictable file naming
- you want to avoid storing per-token metadata on-chain
Avoid it when:
- metadata must be permanently known at mint time
- each token needs unique on-chain generated metadata
- the project requires fully immutable metadata from the start
In those cases, a different architecture may be more appropriate.
Conclusion
A safe ERC721 metadata reveal pattern is mostly about clarity: clear placeholder behavior, clear reveal rules, and clear post-reveal URI resolution. By keeping the contract simple and the metadata layout deterministic, you reduce gas costs and make the collection easier to audit and operate.
The example in this tutorial provides a solid foundation for most NFT reveal workflows. From here, you can add phased reveals, timelocks, or stricter immutability depending on your project’s trust model.
