Blog
5 min readOctoPeeps team

Why your NFT contract should use ipfs:// URIs (and never a gateway hostname)

ipfs://<cid>/<file> survives any single pinning provider going away. https://gateway.example.com/ipfs/<cid> doesn't. Solidity examples and migration paths.

One decision in your tokenURI function determines whether your NFT collection survives the lifetime of any single pinning provider — or breaks the day one of them shuts down. The decision is:

We've watched at least three pinning providers wind down their gateway domains in the past two years (Storacha is the latest). Every project that used the gateway-URL form had to scramble. Every project that used the ipfs:// form did nothing on-chain and their NFTs kept working.

This post explains why.

What the two URIs actually mean

ipfs://<cid>/file.png is a protocol URI. Like mailto: or magnet:, it tells the consumer which protocol to use to resolve the rest. The consumer (a marketplace, a wallet, an indexer) is responsible for translating it to a concrete HTTPS URL via whatever gateway it trusts.

https://gateway.example.com/ipfs/<cid>/file.png is a concrete URL. It points at one specific server. If that server shuts down, the URL 404s.

Your smart contract is immutable. Anything you bake into the URI is permanent unless you ship a contract upgrade with all the complications that entails.

The failure mode of gateway URLs

Imagine you launched in 2022 and put https://gateway.pinata.cloud/ipfs/<cid>/0.json into your contract. Pinata is still around in 2026, so you got lucky. But:

With ipfs://<cid>, none of those concerns apply. Each marketplace / wallet resolves through its own preferred gateway pool. Your contract's URI keeps pointing at a content hash that doesn't change.

The failure mode of ipfs:// URIs

Honesty section. ipfs:// URIs depend on someone on the IPFS network having the bytes. If literally no one pins your CID anywhere, the URI is just a hash that points to nothing.

In practice this only happens if:

The fix is operational, not contractual: re-pin to a working provider. The on-chain URI never needs to change.

How to write it in Solidity

For an ERC-721 with per-token JSON metadata in an IPFS folder:

string private _baseTokenURI = "ipfs://bafybeigid.../";

function tokenURI(uint256 tokenId) public view override returns (string memory) {
    require(_exists(tokenId), "Nonexistent token");
    return string(
        abi.encodePacked(_baseTokenURI, Strings.toString(tokenId), ".json")
    );
}

Yields URIs like ipfs://bafybeigid.../1.json, ipfs://bafybeigid.../2.json, etc. The base URI is the folder CID containing all the JSON files. Each JSON file in turn references its image via an ipfs://<image-cid> URI — never a gateway URL.

What about reveal / generative collections?

Some projects use a placeholder baseURI pre-reveal and update it after the metadata is finalised. The baseURI update is a one-time contract call (assuming the contract has a setter — which it should). Both the pre- and post-reveal URIs should be ipfs://<cid>/ form.

Don't use a gateway URL even for the placeholder. Reveal can take hours, and you don't want a placeholder image returning 503 from an overloaded free-tier gateway when collectors are refreshing OpenSea.

What if I already deployed with a gateway URL?

Common situation. Two paths:

  1. If your contract has a setBaseURI function (most modern OpenZeppelin-based contracts do, gated to the owner): you can flip from the gateway URL to ipfs://<cid>/ in one transaction. Note that some marketplaces cache aggressively so it may take days for the change to propagate everywhere.
  2. If your contract's baseURI is hardcoded immutable: your only option is to keep that gateway alive. Either pay the original provider to keep it running, or run a domain you control that proxies the path. For projects with serious gateway-URL baking, the proxy is the right move — own the domain forever, point it at whichever pinning provider you currently use.

For new deployments: use ipfs://. Always.


See also: how OpenSea / MetaMask / wallets actually resolve ipfs://.