The usual story: you upload images and metadata to IPFS, mint, and everything shows up in wallets and marketplaces. Weeks later the images are broken. Nothing on-chain changed. The data simply stopped being available, because on IPFS an address doesn't guarantee storage.
Why IPFS content disappears
IPFS is content-addressed. A CID (content identifier) is derived from a hash of the data, so bafy... always refers to exactly those bytes and can't be silently swapped. That's what makes it good for NFTs.
But a CID is only a name. Someone still has to store the bytes and announce to the network that they have them. Content stays retrievable only while at least one reachable node pins it (keeps it and won't garbage-collect it) and advertises it.
Typical ways it goes missing:
- You added files with a local node (IPFS Desktop, Kubo) and later shut it down or wiped it. You were the only provider.
- A gateway fetched and cached your file once. Gateway caches are temporary; that was never storage.
- You relied on a single pinning service and the account lapsed, hit a plan limit, or the service changed its terms.
- Your node pinned the data but sat behind NAT and was never reachable, so it "worked" only through your own gateway.
Structure the collection correctly
Upload in two steps, so the metadata can reference the images by CID:
- Add the images directory. You get one CID for the directory.
- Write each token's metadata JSON with
"image": "ipfs://<imagesCID>/1.png". - Add the metadata directory. Its CID becomes your base URI.
ipfs add -r --cid-version 1 ./images # last line: CID of the images directory
ipfs add -r --cid-version 1 ./metadata # last line: CID of the metadata directory
A metadata file (metadata/1.json):
{
"name": "Example #1",
"description": "First token in the Example collection.",
"image": "ipfs://bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi/1.png",
"attributes": [
{ "trait_type": "Background", "value": "Blue" }
]
}
Use CIDv1 (bafy...). CIDv0 (Qm...) is case-sensitive, which breaks subdomain gateways. Also note that the CID depends on how the data was chunked and encoded, not just on the raw bytes. Different tools or settings can give different CIDs for the same file, so record the CIDs your tool actually produced and pin exactly those.
Store ipfs:// URIs, not gateway URLs
Put ipfs://<CID>/... in your contract and metadata, not https://some-gateway.example/ipfs/<CID>/.... A gateway URL ties your NFT to one company's HTTP server. If that gateway shuts down, rate-limits or changes domains, the link breaks even though the content is still on IPFS. Wallets and marketplaces resolve ipfs:// URIs through whatever gateway they choose.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
contract ExampleNFT is ERC721 {
using Strings for uint256;
string private constant BASE_URI =
"ipfs://bafybeibnsoufr2renqzsh347nrx54wcubt5lgkeivez63xvivplfwhtpym/";
constructor() ERC721("Example", "EXM") {}
function tokenURI(uint256 tokenId) public view override returns (string memory) {
_requireOwned(tokenId);
return string.concat(BASE_URI, tokenId.toString(), ".json");
}
}
OpenZeppelin's default tokenURI returns baseURI + tokenId with no .json extension. Either override it as above or name your files 1, 2, 3 without an extension. A mismatch here is a common cause of "metadata not found" that has nothing to do with pinning. The CIDs in these examples are placeholders; use your own.
Pin in more than one place
Treat pinning like backups: more than one independent copy.
-
A pinning service. Upload through its API or pin CIDs you already have. Many services implement the standard IPFS Pinning Service API, which Kubo can talk to directly:
ipfs pin remote service add mysvc https://pinning.example.com/psa "$PINNING_TOKEN" ipfs pin remote add --service=mysvc --name=example-images bafy...imagesCID ipfs pin remote add --service=mysvc --name=example-metadata bafy...metadataCID -
A second, unrelated provider, so one vendor's billing or outage can't take the collection down.
-
Your own node, if you can keep one running and publicly reachable. Pinned content is announced to the network automatically.
-
Filecoin storage deals, if you want storage that providers are paid and checked to keep, usually through a service that handles the deals.
Keep an offline archive too. A CAR file holds the content together with its IPFS structure, so re-importing it anywhere reproduces the same CIDs:
ipfs dag export bafy...metadataCID > metadata.car
ipfs dag import metadata.car # on any node, later
Never keep your pinning API token in frontend code or a public repo. Anyone holding it can unpin your content.
Verify before you mint
Check that the content is reachable from nodes other than your own:
ipfs routing findprovs bafy...imagesCID # who is providing this CID
Then fetch a few metadata/<id>.json and image paths through two or three different public gateways, from a machine that isn't your pinning node. If only your own gateway can load them, fix that before minting.
If you need to update metadata later
The CID changes whenever the content changes, so "updating" means pinning a new directory and pointing the contract at the new base URI. If you do that, emit the ERC-4906 MetadataUpdate or BatchMetadataUpdate event so indexers refresh. If you promise holders the metadata is permanent, make the base URI immutable (a constant, as above, or a setter that can be locked) and say so.
Checklist
- Add images, then metadata, with
--cid-version 1; reference images byipfs://inside the JSON. - Store
ipfs://<CID>/as the base URI, never a gateway URL. - Match file names to what
tokenURIreturns (extension or no extension). - Pin with at least two independent providers and keep a CAR backup.
- Verify retrieval through multiple gateways from outside your network before minting.
- Keep pinning tokens secret, and monitor your pins and billing.
Get the weekly commit
New blockchain deep dives every week.