Managing NFT Metadata with IPFS
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. 
- baseURIpattern: 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-globRun 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 dotenvInitialise 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 imagesUpload 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-globRun 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.jsUpload 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 
- ERC721is the core NFT logic (ownership, transfers, approvals).
- ERC721URIStorageadds per-token storage for URIs via- _setTokenURI.
- Ownablesimple 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:- tois the recipient address (can be yours).
- uriis- ipfs://<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
