Posts

Pinning NFT Images and Metadata to IPFS with Pinata

An NFT is not the image. For a typical ERC-721 token, the smart contract returns a metadata URI. The metadata document at that URI describes the token and points to its image. If either pointer can be changed without …

November 3, 2021 10 min read 2009 words

In this article

An NFT is not the image.

For a typical ERC-721 token, the smart contract returns a metadata URI. The metadata document at that URI describes the token and points to its image. If either pointer can be changed without limits, the thing a collector sees can change even though the token itself never moved.

That is a trust problem.

In this post, I will use IPFS and Pinata to give the image and metadata layers content-addressed identities. I will also use my Pinata IPFS scripts for NFT projects to pin images independently and pin the finished metadata as a directory.

The order matters:

images -> image CIDs -> metadata JSON -> metadata directory CID -> contract

You cannot write the final image value into a metadata document until the image has a CID. Upload and verify the images before generating the metadata.

What IPFS Does—and What It Does Not Do

IPFS identifies content using a Content Identifier, or CID. If the encoded IPFS content changes, its CID changes. When a client successfully retrieves content for a CID, it can verify that it received the content identified by that CID.

That gives us integrity. It does not give us every other property we might want.

CID                     -> identifies and verifies content
Pinning                 -> keeps content available from a provider
Metadata                -> connects a token to its media and traits
Immutable contract URI  -> prevents the issuer from swapping metadata

A CID does not guarantee that somebody will continue hosting the corresponding blocks. Pinning handles that availability problem. A pinned CID also does not stop a contract owner from changing baseURI to an entirely different CID. The contract has to enforce that rule.

Treat content integrity, availability, and contract mutability as separate requirements. IPFS content addressing provides the first; pinning and contract design have to provide the others.

If you want the broader content-addressing explanation first, read Why You Should Use IPFS. This post stays focused on the NFT workflow.

The Storage Design

For this project, I use two different upload strategies:

  1. Upload every image independently. Each image receives its own CID, and no public image-directory path exposes neighboring filenames.
  2. Upload all metadata documents as one directory. The directory CID becomes the contract’s base URI, and predictable filenames map token IDs to metadata.

The result looks like this:

token 0
  -> ipfs://<METADATA_DIRECTORY_CID>/0.json
       -> image: ipfs://<IMAGE_0_CID>

token 1
  -> ipfs://<METADATA_DIRECTORY_CID>/1.json
       -> image: ipfs://<IMAGE_1_CID>

This prevents somebody from learning every image CID merely by changing 0.png to 1.png under a shared image-directory CID.

It does not make revealed metadata private. Once the metadata-directory CID is public, anyone can request predictable files such as 0.json, 1.json, and 2.json. I will return to that boundary when we discuss reveal timing.

Prerequisites

You will need:

  • a current Node.js installation;
  • a Pinata account;
  • a Pinata API credential with only the permissions the upload scripts require;
  • the final images and trait data for the collection;
  • enough Pinata storage for both the images and metadata.

Clone the scripts and install their dependencies:

git clone https://github.com/Coderrob/pinata-ipfs-scripts-for-nft-projects.git
cd pinata-ipfs-scripts-for-nft-projects
npm install

Compatibility note: I verified this workflow against repository commit 8c71115. These scripts use Pinata’s legacy API-key and secret authentication. Pinata’s current SDK uses JWT authentication and newer public-upload methods. The upload design in this post is still the same, but check the repository README and Pinata’s current SDK documentation before creating credentials. If the scripts move to the current SDK, replace the legacy credential setup with the documented JWT configuration.

For the legacy scripts pinned to the referenced commit, create a local .env file:

PINATA_API_KEY="replace-me"
PINATA_API_SECRET="replace-me"

Do not commit that file. Do not put either credential in browser code. A Pinata credential can create, list, or remove stored content according to its permissions, so treat it like the production secret it is.

The individual-file uploader also reads output/downloaded-cids.json before it starts. If that file does not exist yet, create it with an empty object:

{}

That gives the script a valid starting manifest so the first upload does not fail because the file is missing.

Also remember that public IPFS content is public. Do not upload unrevealed data under a CID you plan to share, and do not upload private information unless it has been encrypted appropriately.

Step 1: Prepare the Images

Place the final images in the repository’s files/ directory. Name each file so it can be mapped back to a token ID:

files/
├── 0.png
├── 1.png
└── 2.png

Before uploading, validate the inputs that would otherwise require repinning content and updating the metadata:

  • every expected token ID has one image;
  • there are no duplicate IDs;
  • file extensions and media types are correct;
  • dimensions and file sizes meet the marketplace requirements you care about;
  • the files really are final.

A CID identifies the uploaded content; it does not confirm that the content is correct for the collection. Complete these checks before publishing the CID or writing it into metadata.

Step 2: Pin Every Image Independently

Run the individual-file uploader:

node ./src/upload-files.js

The script uploads each file separately and writes the filename-to-CID mapping to:

output/uploaded-cids.json

The output looks like this:

{
  "0.png": "Qm...image0",
  "1.png": "Qm...image1",
  "2.png": "Qm...image2"
}

The current upload-files.js explicitly requests CIDv0, so its values begin with Qm. CIDv1 values are commonly base32 strings beginning with b. Both identify IPFS content, although the IPFS NFT data guidance recommends CIDv1 in base32 for new NFT data. Moving these scripts to Pinata’s current SDK is the right time to make that migration intentionally and regenerate the manifests.

Keep this mapping with the project source. Metadata generation depends on it, and you will need it to reproduce or audit the filename-to-CID assignment.

The uploader also rate-limits requests and writes its completed manifest at the end. A collection with thousands of images can take hours, so interrupted work needs a recovery plan. Use download-cids.js to rebuild the known Pinata mappings before retrying, and test that recovery path with a small batch before using it for a full collection. For a production pipeline, I would add incremental checkpoints, bounded retries, and an explicit failure report so an interruption does not discard the upload progress.

Step 3: Generate the Metadata Documents

ERC-721 defines a small metadata schema containing name, description, and image. Marketplaces commonly support additional fields such as attributes, but those are ecosystem conventions rather than required ERC-721 fields. The ERC-721 specification defines the standard requirements.

A metadata document for token 0 might look like this:

{
  "name": "Example Collection #0",
  "description": "A member of the Example Collection.",
  "image": "ipfs://bafy...image0",
  "attributes": [
    {
      "trait_type": "Background",
      "value": "Blue"
    }
  ]
}

Use the canonical ipfs:// URI in the metadata. Do not store a Pinata or ipfs.io gateway URL as the permanent image reference. Applications can translate an IPFS URI into whichever HTTP gateway they use for retrieval.

For a collection larger than a few tokens, generate the documents rather than editing them by hand. The following small Node script reads the uploaded CID manifest and creates one metadata file per image:

import { mkdir, readFile, writeFile } from "node:fs/promises";
import { parse } from "node:path";

const cidManifest = JSON.parse(
  await readFile("./output/uploaded-cids.json", "utf8"),
);

await mkdir("./metadata", { recursive: true });

for (const [filename, cid] of Object.entries(cidManifest)) {
  const tokenId = parse(filename).name;
  const metadata = {
    name: `Example Collection #${tokenId}`,
    description: "A member of the Example Collection.",
    image: `ipfs://${cid}`,
    attributes: [],
  };

  await writeFile(
    `./metadata/${tokenId}.json`,
    `${JSON.stringify(metadata, null, 2)}\n`,
    "utf8",
  );
}

Save it as generate-metadata.mjs, replace the example copy, and merge in the real trait data for the project. Then run:

node ./generate-metadata.mjs

The resulting directory should match the filename convention used by the contract:

metadata/
├── 0.json
├── 1.json
└── 2.json

My contract tutorial appends ".json" to the token ID. The scripts repository also demonstrates extensionless files such as 0. Either convention works. Mixing them does not.

Step 4: Validate Before Uploading

Do not make the upload command your first validation pass. At minimum, verify that:

  1. The number of metadata files matches the intended collection supply.
  2. Every token ID is present exactly once.
  3. Every file contains valid UTF-8 JSON.
  4. Every image value uses ipfs:// and appears in the uploaded CID manifest.
  5. Every trait has the intended name, type, and value.
  6. No unrevealed property leaked into a placeholder document or public manifest.

Open several JSON files manually as well. Inspecting token 0, a token in the middle, and the final token helps detect incorrect ranges, missing endpoints, and off-by-one errors.

Step 5: Pin the Metadata Directory

Run the folder uploader:

node ./src/upload-folder.js

The script uploads metadata/ as a directory and writes its root CID to:

output/folder-cid.json

For example:

{
  "metadata": "Qm...metadataDirectory"
}

The canonical base URI is now:

ipfs://<METADATA_DIRECTORY_CID>/

For a contract that appends <tokenId>.json, token 0 resolves to:

ipfs://<METADATA_DIRECTORY_CID>/0.json

Changing any metadata document and uploading the directory again produces a different root CID. The old CID still identifies the old directory; whether anyone can retrieve it depends on whether its blocks remain available.

Step 6: Verify Through Gateways

An HTTP gateway lets software retrieve IPFS content without resolving an ipfs:// URI directly. The gateway URL is not the canonical identifier, and requesting content through a gateway does not mean that gateway pinned it.

Retrieve a few documents through Pinata and an independent public gateway:

curl https://gateway.pinata.cloud/ipfs/<METADATA_DIRECTORY_CID>/0.json
curl https://dweb.link/ipfs/<METADATA_DIRECTORY_CID>/0.json

Then retrieve the image CID from each returned document. Repeat this for the first, middle, and final token IDs.

Gateway requests can fail because a gateway is unavailable, rate-limited, or still discovering providers. That does not change the CID. Monitor retrieval availability separately from content integrity.

Step 7: Connect and Freeze the Contract

Only after verification should the metadata base URI become part of the contract workflow.

For a revealable collection, do not publish the retrievable metadata-directory CID before reveal. Publish a cryptographic commitment to the final base URI, close minting when appropriate, and reveal the committed URI later. Once revealed, do not leave an unrestricted function that can replace it.

I walk through that contract, its tests, and the reveal commitment in Building a Revealable PFP NFT Contract with ERC-721 and IPFS.

These properties remain separate:

  • Independent image CIDs prevent walking a shared image directory.
  • The metadata directory remains walkable once its CID is disclosed.
  • A hidden CID is not encryption or access control.
  • Contract rules—not IPFS—determine whether the published metadata URI can later be replaced.

Do not claim that an NFT can never change unless the deployed contract and every referenced layer enforce that claim. Document the exact update behavior the contract permits.

Plan for Persistence

Pinata keeps uploaded content available according to the pins retained for your account and service plan. IPFS content addressing also makes the data portable: another provider or your own node can pin the same CID without changing the token metadata.

For a real collection, I would keep:

  • the original images and metadata in durable project backups;
  • the image and metadata CID manifests under version control where appropriate;
  • the metadata directory pinned by more than one provider or node;
  • automated checks that periodically retrieve representative tokens;
  • a documented recovery plan for provider, billing, or credential failures.

The IPFS persistence documentation explains why cached content may be garbage-collected and why important data needs explicit pinning. Pinning requires ongoing ownership, monitoring, and a recovery plan.

Final Checklist

Before connecting an NFT contract to the metadata, verify all of the following:

  • Every image was uploaded independently.
  • The image CID manifest is complete and backed up.
  • Every metadata document uses an ipfs:// image URI.
  • Metadata filenames match the contract’s token URI convention.
  • The metadata count and token-ID range match the supply.
  • The metadata directory is pinned and retrievable.
  • The final base URI was tested through multiple gateways.
  • Unrevealed metadata has not been published prematurely.
  • The contract commits to or permanently freezes the final URI.
  • A redundant persistence and monitoring plan exists.

The NFT, metadata, media, storage provider, and contract are separate parts of the system. A reliable collection requires correct files, verified CIDs, durable pinning, accurate metadata, and contract rules that restrict URI changes as promised.

Document and test each requirement before minting.