Using IPFS for ERC721 Metadata
In this guide, you will rely on an IPFS workflow for ERC721 collections on Somnia.
This guide will walk you through how - You will prepare and upload artwork to IPFS via Pinata. - Generate clean, wallet/marketplace-friendly metadata JSON - Upload the metadata to IPFS via Pinata Cloud - Deploy a Solidity contract that stores the metadata URIs onchain (for each token) - Mint your NFTs.
All metadata resides on IPFS, while only the URIs (pointers) are stored onchain.
Please note that this guide provides a Smart Contract option for fully onchain metadata.
Concepts You Should Know
IPFS (InterPlanetary File System) is a “Content Addressed Storage Network”. Files are addressed by their CID (content identifier). If any bit changes, the CID changes, and it is great for integrity and verifiability.
Pinata: Pinata pins your files to IPFS and keeps them available. You’ll get CIDs (content identifiers) that never change for the same content.
TokenURI: an ERC721 method that returns a URL (often an ipfs:// URI) pointing to a JSON file describing the NFT token (name, description, image, attributes, etc.).
Per token URI vs
baseURI
:Per token URI (this guide): store the full JSON URI onchain for each token with ERC721URIStorage. Easiest and most flexible.
baseURI
pattern: compute tokenURI = baseURI + tokenId (no per token storage). Cheaper for large drops, but requires sequential naming.
Prerequisites
MetaMask is configured for a Somnia RPC and has sufficient funds for gas.
Node.js ≥ 18 (only if you’ll run the helper scripts below).
Pinata account and a JWT (recommended): Get your JWT on Pinata
Pinata Dashboard → API Keys → New Key → choose Admin/Scoped as needed → copy JWT
.
Prepare Images
If your art varies wildly in size or format, normalize once for a consistent user experience. Create an assets
directory for your images. Run the node script below, targeting the assets
directory to give uniformity to your images.
Install the dependencies:
npm i sharp fast-glob
Run the script:
// scripts/resize.js
import fg from "fast-glob";
import sharp from "sharp";
import { mkdirSync } from "fs";
import path from "path";
const INPUT = "assets/raw-images"; // put your original images here
const OUTPUT = "assets/images";
mkdirSync(OUTPUT, { recursive: true });
const files = (await fg(`${INPUT}/*.{png,jpg,jpeg,webp}`)).sort();
for (let i = 0; i < files.length; i++) {
const out = path.join(OUTPUT, `${i}.png`);
await sharp(files[i]).resize(1024, 1024, { fit: "cover" }).toFile(out);
}
console.log("Normalized images written to", OUTPUT);
Recommended Folder Structure
project/
assets/
images/
0.png
1.png
2.png
...
metadata/
0.json
1.json
2.json
...
scripts/
resize.js
pinata.ts (Pinata SDK init)
upload-images.ts (pin images folder → IMAGES_CID)
make-metadata.ts (generate JSON → uses IMAGES_CID)
upload-metadata.ts (pin metadata folder → METADATA_CID)
images/
contain consistent dimensions and formats (e.g., 1024×1024 PNG).metadata/
contain one JSON per token ID, matching the filename (e.g., 7.json for tokenId 7).
Upload Images to IPFS via Pinata
Install Pinata SDK:
npm i pinata dotenv
Initialise Pinata using your JWT credentials:
// pinata.ts
import 'dotenv/config'
import { PinataSDK } from 'pinata'
export const pinata = new PinataSDK({
pinataJwt: process.env.PINATA_JWT!, // from Pinata “API Keys”
pinataGateway: process.env.PINATA_GATEWAY!, // e.g. myxyz.mypinata.cloud
})
Create a .env
file in your project root:
PINATA_JWT=eyJhbGciOi... # your Pinata JWT (keep secret)
PINATA_GATEWAY=myxyz.mypinata.cloud
IMAGES_CID= # leave blank until you upload images
Upload the images directory and get the root CID:
// scripts/upload-images.ts
import 'dotenv/config'
import { pinata } from './pinata'
import fs from 'node:fs/promises'
import path from 'node:path'
import { File } from 'node:buffer'
const DIR = 'assets/images'
async function main() {
const names = (await fs.readdir(DIR))
.filter(n => n.match(/\.(png|jpg|jpeg|webp)$/i))
.sort((a, b) => Number(a.split('.')[0]) - Number(b.split('.')[0]))
const files: File[] = []
for (const name of names) {
const bytes = await fs.readFile(path.join(DIR, name))
files.push(new File([bytes], name)) // uploaded as a single folder
}
const res = await pinata.upload.public.fileArray(files)
// res.cid is the directory root CID
console.log('Images CID:', res.cid)
}
main().catch(console.error)
Run the following command to upload the images:
node dist/scripts/upload-images.js
# => Images CID: bafybe...
Copy the CID into .env
as IMAGES_CID=....
Generate Metadata JSON
Each *.json should follow the de facto standards:
{
"name": "NFTTest #0",
"description": "A clean ERC-721 on Somnia with per-token metadata.",
"image": "ipfs://IMAGES_CID/0.png",
"external_url": "https://your-site.example",
"attributes": [
{ "trait_type": "Edition", "value": 0 }
]
}
Install fast-glob
library:
npm i fs-extra fast-glob
Run the script below to generate a file per image:
/ scripts/make-metadata.js
import fg from "fast-glob";
import { writeJSON, mkdirs } from "fs-extra";
import path from "path";
const IMAGES_CID = process.env.IMAGES_CID || "bafy..."; // paste from Step 2
const OUT_DIR = "assets/metadata";
sync function main() {
await mkdirs(OUT_DIR)
const imgs = (await fg('assets/images/*.{png,jpg,jpeg,webp}')).sort((a, b) =>
Number(path.basename(a).split('.')[0]) - Number(path.basename(b).split('.')[0])
)
for (const p of imgs) {
const id = Number(path.basename(p).split('.')[0])
const json = {
name: `NFTTest #${id}`,
description: 'ERC-721 on Somnia with Pinata-hosted IPFS metadata.',
image: `ipfs://${IMAGES_CID}/${id}.png`,
attributes: [{ trait_type: 'Edition', value: id }]
}
await writeJSON(path.join(OUT_DIR, `${id}.json`), json, { spaces: 2 })
}
console.log('Metadata written to', OUT_DIR)
}
main().catch(console.error)
To create the metadata files, run the command:
node dist/scripts/make-metadata.js
Upload Metadata to IPFS
Upload the metadata folder to generate the METADATA_CID
// scripts/upload-metadata.ts
import { pinata } from './pinata'
import fs from 'node:fs/promises'
import path from 'node:path'
import { File } from 'node:buffer'
const DIR = 'assets/metadata'
async function main() {
const names = (await fs.readdir(DIR))
.filter(n => n.endsWith('.json'))
.sort((a, b) => Number(a.split('.')[0]) - Number(b.split('.')[0]))
const files: File[] = []
for (const name of names) {
const bytes = await fs.readFile(path.join(DIR, name))
files.push(new File([bytes], name, { type: 'application/json' }))
}
const res = await pinata.upload.public.fileArray(files)
console.log('Metadata CID:', res.cid)
}
main().catch(console.error)
Run the command:
node dist/scripts/upload-metadata.js
# => Metadata CID: bafybe...
Your tokenURI format is now: ipfs://<METADATA_CID>/<tokenId>.json
Make sure the script uploads assets/metadata
and records the printed Metadata CID; you’ll mint with: ipfs://<METADATA_CID>/<tokenId>.json
NFT Smart Contract
We’ll use ERC721URIStorage
Smart Contract and mint with full URIs. Paste this into Remix as NFTTest.sol
Contract Walkthrough
Imports
ERC721
is the core NFT logic (ownership, transfers, approvals).ERC721URIStorage
adds per-token storage for URIs via_setTokenURI
.Ownable
simple admin model; exposes onlyOwner for minting control.
ERC721URIStorage
is the most straightforward way to store a token’s exact URI. For large drops, consider using a baseURI pattern to save storage gas; otherwise, this is perfect for flexible, explicit URIs.
State (_nextTokenId) is the sequential ID counter starting at 0. First mint → tokenId = 0, then 1, 2, … etc
Constructor
Initializes collection name/symbol and sets the owner to initialOwner
(the only account allowed to mint).
_baseURI()
Returns https://ipfs.io. This is only used if you mint with relative paths (e.g., /ipfs/CID/7.json). If you mint with absolute ipfs://… URIs (recommended), this value is ignored.
safeMint(to, uri):
Owner-only mint. Creates a new token ID, mints safely (checking receiver contracts implement IERC721Receiver), and stores the exact uri string for that token via _setTokenURI.
Compile and Deploy using Remix. Follow this guide
Copy the deployed contract address.
How To Mint NFTs (Per Token URIs)
n Remix (Deployed Contracts):
Call
safeMint(to, uri)
where:to
is the recipient address (can be yours).uri
isipfs://<METADATA_CID>/<id>.json
(e.g.,ipfs://bafy.../0.json
).
Then call tokenURI(0)
to confirm the stored URI.
If you prefer scripts for multiple mints, you can write a script to Batch Mint using Hardhat, for example:
scripts/mint.ts (Hardhat)
import { ethers } from "hardhat";
const CONTRACT = "0xYourDeployedAddress";
const RECEIVER = "0xReceiver";
const METADATA_CID = "bafy...";
async function main() {
const nft = await ethers.getContractAt("NFTTest", CONTRACT);
for (let id = 0; id < 10; id++) {
const uri = `ipfs://${METADATA_CID}/${id}.json`;
const tx = await nft.safeMint(RECEIVER, uri);
await tx.wait();
console.log(`Minted #${id} → ${uri}`);
}
}
main().catch(console.error);
Validation
To validate everything end to end, first do quick gateway smoke tests by opening https://ipfs.io/ipfs/<IMAGES_CID>/0.png
and https://ipfs.io/ipfs/<METADATA_CID>/0.json
in a browser to confirm the image and JSON are reachable. Next, call tokenURI(0)
on your contract and verify it returns ipfs://<METADATA_CID>/0.json
. Finally, check in a wallet or marketplace UI that the NFT renders correctly using the ipfs://
image URI embedded in the JSON.
MetadataOptional Fully Onchain Metadata
Last updated