Build Your First Solana NFT Collection with Metaplex Core

Want to understand how Mad Lads, Okay Bears, or DeGods actually work? Build your own NFT collection on Solana.
In the next 90-120 minutes, you'll deploy a complete NFT collection to Solana's devnet. A real collection that appears in Phantom wallet, can be listed on Magic Eden, and follows the same Metaplex standard powering billions in Solana NFT trading volume. More importantly, you'll learn why Solana NFTs cost fractions of a cent to mint, how the account model differs from Ethereum's contract model, and what makes Metaplex the backbone of Solana's NFT ecosystem.
Note: If you've completed the ERC-721 guide, you'll notice some conceptual similarities--but Solana's architecture works differently from the ground up. No smart contracts here. Instead, you'll interact with pre-deployed programs and create accounts that hold your NFT data.
What You'll Learn
You'll understand the architecture behind every successful Solana NFT project:
- Why Solana NFTs cost less than $0.01 to mint: Solana's account model and rent system mean you're paying for storage space, not computation. A single NFT mint costs roughly 0.01 SOL (~$0.01-0.02), compared to $50-150+ on Ethereum (and $500+ during network congestion).
- How Metaplex became the NFT standard: Unlike Ethereum where every project deploys its own contract, Solana projects share the same Metaplex programs. This creates instant ecosystem compatibility because every wallet and marketplace speaks the same language.
- Why there are no "smart contracts" to write: Solana uses pre-deployed programs. You don't write Solidity-like code; instead, you call existing programs with the right account structures. Metaplex handles the complexity.
- Where your NFT data actually lives: Token accounts, metadata accounts, master edition accounts. Understanding this architecture is critical to grasping how Solana NFTs work.
What You Need
| Requirement | Why It Matters | Get It Here |
|---|---|---|
| Node.js & npm | Runs the TypeScript tooling and manages dependencies | nodejs.org |
| Solana CLI | Creates wallets, airdrops test SOL, and configures your local environment | docs.solana.com |
| Phantom wallet | Browser wallet for viewing your NFTs and interacting with Solana dApps | phantom.app |
| Devnet SOL | Free test tokens for development (we'll airdrop these via CLI) | Built into Solana CLI |
| Arweave storage | Hosts your NFT images and metadata permanently--the Solana ecosystem standard | Via Irys (built into Metaplex) |
Understanding What You're Building
Let's start with why Solana NFTs work so differently from Ethereum.
The Account Model vs. Contract State
On Ethereum, you deploy a smart contract that stores all NFT ownership in its internal state. The contract is the source of truth. When you call ownerOf(tokenId), the contract looks up its mapping and returns the owner.
Solana flips this model. Instead of one contract holding everything, each NFT creates multiple accounts on the blockchain:
- Mint Account: The NFT's unique identifier (like a token address)
- Token Account: Holds the actual token, owned by a wallet
- Metadata Account: Stores name, symbol, URI, and creator info
- Master Edition Account: Proves this is a 1-of-1 NFT (not a fungible token)
These accounts are created by calling the Metaplex Token Metadata Program--a pre-deployed, audited program that every Solana NFT uses. You don't write the program. You call it with the right parameters.
Why Pre-Deployed Programs?
Imagine if every ERC-721 project on Ethereum used the exact same contract code, deployed once and shared by everyone. That's exactly how Solana works.
Benefits:
- Instant compatibility: Wallets, marketplaces, and tools integrate once with Metaplex, not with thousands of individual contracts
- Security through auditing: One codebase to audit, not thousands of variations
- Lower costs: No contract deployment fees. You only pay for account creation
Trade-offs:
- Less customization: You can't add arbitrary logic like Ethereum contracts
- Standardized behavior: The program defines what's possible. Extensions require new programs
Metaplex Standards: Which One to Use?
Metaplex offers three NFT standards. Here's when to use each:
| Standard | Best For | Cost per NFT | Key Feature |
|---|---|---|---|
| Core (Recommended) | New projects, modern apps | ~0.0029 SOL | 85% cheaper, plugin system |
| Token Metadata | Ecosystem compatibility | ~0.016 SOL | Widest marketplace support |
| Bubblegum (cNFT) | Mass airdrops, gaming | ~0.00001 SOL | Compressed, Merkle tree storage |
For this guide, we'll use Metaplex Core, the newest standard and the recommended choice for all new projects. It's dramatically cheaper and includes a flexible plugin system for royalties, freezing, and custom behaviors.
Project Setup
We'll create a TypeScript project using Metaplex's Umi framework--the modern, type-safe way to interact with Solana programs.
1. Install the Solana CLI
First, install Solana's command-line tools. This gives you wallet management and devnet access.
macOS/Linux:
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
Windows:
Download and run the installer from Solana's releases page.
After installation, add Solana to your PATH and verify:
solana --version
You should see something like solana-cli 2.x.x.
2. Configure Solana for Devnet
Set your CLI to use Solana's devnet (test network):
solana config set --url https://api.devnet.solana.com
Verify your configuration:
solana config get
You should see RPC URL: https://api.devnet.solana.com.
3. Create a Development Wallet
Generate a new keypair for development:
solana-keygen new --outfile ~/.config/solana/id.json
You'll be prompted to create a passphrase (optional for devnet). Save the seed phrase shown, though for devnet testing, it's not critical.
Set this wallet as your default:
solana config set --keypair ~/.config/solana/id.json
Check your wallet address:
solana address
Save this address. It's your public key on Solana.
4. Fund Your Wallet with Test SOL
Airdrop free devnet SOL to your wallet:
solana airdrop 2
Verify the balance:
solana balance
You should see 2 SOL. If the airdrop fails (rate limits), try again in a minute or use the Solana Faucet.
5. Initialize Your NFT Project
Create a new directory and initialize a TypeScript project:
mkdir solana-nft
cd solana-nft
npm init -y
Install the required Metaplex packages:
npm install @metaplex-foundation/mpl-core @metaplex-foundation/umi @metaplex-foundation/umi-bundle-defaults @metaplex-foundation/umi-uploader-irys @solana/web3.js
Install TypeScript and development dependencies:
npm install -D typescript ts-node @types/node
Create a TypeScript configuration file:
npx tsc --init
Update tsconfig.json with these settings:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Create the source directory:
mkdir src
What each package provides:
@metaplex-foundation/mpl-core: The Core NFT standard programs and instructions@metaplex-foundation/umi: Metaplex's framework for building Solana transactions@metaplex-foundation/umi-bundle-defaults: Pre-configured Umi setup with standard plugins@metaplex-foundation/umi-uploader-irys: Upload files to Arweave via Irys (formerly Bundlr)@solana/web3.js: Low-level Solana JavaScript SDK
Creating Your First NFT
We'll create a single NFT to understand the core concepts, then expand to collections.
1. Setup Script Structure
Create src/createNft.ts:
import { create, mplCore, fetchAssetV1 } from "@metaplex-foundation/mpl-core";
import {
generateSigner,
keypairIdentity,
} from "@metaplex-foundation/umi";
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { base58 } from "@metaplex-foundation/umi/serializers";
import { readFileSync } from "fs";
import { homedir } from "os";
import path from "path";
async function main() {
// 1. Initialize Umi with devnet connection
const umi = createUmi("https://api.devnet.solana.com").use(mplCore());
// 2. Load your wallet keypair (use id.json or your configured keypair)
const walletPath = path.join(homedir(), ".config/solana/id.json");
const secretKey = JSON.parse(readFileSync(walletPath, "utf-8"));
const keypair = umi.eddsa.createKeypairFromSecretKey(
new Uint8Array(secretKey)
);
umi.use(keypairIdentity(keypair));
console.log("Wallet address:", keypair.publicKey);
console.log("Creating NFT...\n");
// 3. Generate a new keypair for the NFT asset
const asset = generateSigner(umi);
// 4. Create the NFT
const tx = await create(umi, {
asset,
name: "My First Solana NFT",
uri: "https://arweave.net/example-metadata-uri", // We'll replace this
}).sendAndConfirm(umi);
// 5. Deserialize the signature for display
const signature = base58.deserialize(tx.signature)[0];
console.log("✅ NFT Created!");
console.log("Asset Address:", asset.publicKey);
console.log("Transaction Signature:", signature);
// 6. View links
console.log("\nView Transaction on Solana Explorer:");
console.log(`https://explorer.solana.com/tx/${signature}?cluster=devnet`);
console.log("\nView NFT on Metaplex Explorer:");
console.log(`https://core.metaplex.com/explorer/${asset.publicKey}?env=devnet`);
// 7. Fetch and display the NFT data
const fetchedAsset = await fetchAssetV1(umi, asset.publicKey);
console.log("\nNFT Details:");
console.log("- Name:", fetchedAsset.name);
console.log("- URI:", fetchedAsset.uri);
console.log("- Owner:", fetchedAsset.owner);
}
main().catch(console.error);
Key concepts:
createUmi(): Initializes the Umi client connected to devnetmplCore(): Adds Metaplex Core program support to UmigenerateSigner(): Creates a new keypair for your NFT's addresscreate(): The Core program instruction that creates an NFTbase58.deserialize(): Converts the signature to a readable stringfetchAssetV1(): Reads NFT data back from the blockchain
2. Understanding the Transaction
When you call create(), Metaplex builds a transaction that:
- Creates the asset account : A new account on Solana holding the NFT data
- Sets the owner: Your wallet becomes the NFT owner
- Stores on-chain data: Name and URI are written to the account
- Pays rent: Solana charges ~0.003 SOL for account storage
The uri field points to off-chain metadata (JSON file) containing the full description, image link, and attributes. We'll create this next.
3. Add Plugins for Extended Functionality
Metaplex Core uses a plugin system to add behaviors to your NFTs. You can add plugins during creation:
import { create, mplCore, ruleSet } from "@metaplex-foundation/mpl-core";
// In your create() call, add plugins:
const tx = await create(umi, {
asset,
name: "My First Solana NFT",
uri: "https://arweave.net/example-metadata-uri",
plugins: [
{
type: "Royalties",
basisPoints: 500, // 5% royalty
creators: [
{
address: keypair.publicKey,
percentage: 100,
},
],
ruleSet: ruleSet("None"),
},
{
type: "PermanentFreezeDelegate",
frozen: false,
authority: { type: "UpdateAuthority" },
},
],
}).sendAndConfirm(umi);
Plugin types available:
Royalties: Secondary sale fees enforced by marketplacesPermanentFreezeDelegate: Freeze transfers permanently (useful for soulbound tokens)FreezeDelegate: Allow an authority to freeze/unfreeze transfersTransferDelegate: Allow another account to transfer the NFTBurnDelegate: Allow another account to burn the NFTAttributes: On-chain key-value attributes (for games, dynamic NFTs)AppData: Store custom application data on-chain
Preparing NFT Metadata
Your NFT's visual identity--name, image, description, traits--lives in a JSON metadata file stored off-chain. Solana's account stores only a URI pointing to this file.
Understanding Metadata Structure
Metaplex follows a metadata standard compatible with marketplaces like Magic Eden and Tensor:
{
"name": "Cool Cat #1",
"symbol": "CCAT",
"description": "A unique Cool Cat from the Solana Cool Cats collection.",
"image": "https://gateway.irys.xyz/YOUR_IMAGE_TX_ID",
"animation_url": "",
"external_url": "https://coolcats.io",
"attributes": [
{
"trait_type": "Background",
"value": "Blue"
},
{
"trait_type": "Fur",
"value": "Golden"
},
{
"trait_type": "Eyes",
"value": "Laser"
},
{
"trait_type": "Rarity Score",
"value": 87,
"display_type": "number"
}
],
"properties": {
"files": [
{
"uri": "https://gateway.irys.xyz/YOUR_IMAGE_TX_ID",
"type": "image/png"
}
],
"category": "image",
"creators": [
{
"address": "YOUR_WALLET_ADDRESS",
"share": 100
}
]
}
}
Field breakdown:
name: Display name for the NFTsymbol: Short identifier (like a ticker)description: Full description shown on marketplacesimage: Primary image URL (typically Arweave on Solana)attributes: Traits for filtering and rarity calculationproperties.files: All associated media filesproperties.creators: Wallet addresses receiving royalties
Uploading to Arweave via Irys
Arweave is the standard storage layer for Solana NFTs. Unlike IPFS (common on Ethereum), where you need a pinning service with recurring monthly fees to keep files available, Arweave uses a pay-once model--a single upfront payment stores your files permanently. Metaplex has built-in Irys integration.
Why Arweave for Solana?
- Ecosystem standard: Magic Eden, Tensor, and other marketplaces expect Arweave URIs
- Permanent storage: Pay once, no monthly pinning fees (IPFS requires ongoing payments to pinning services)
- Native Metaplex support: Built-in
umi-uploader-iryspackage - Pay with SOL: No need for separate tokens or accounts
1. Create Your Asset Folders
mkdir -p nft-assets/images
mkdir -p nft-assets/metadata
2. Add Your Images
For this tutorial, we'll create a small 5-NFT collection. You'll need:
Images: Create or download 5 images (PNG or JPG format, ideally 1000x1000px or larger). Name them 1.png, 2.png, 3.png, 4.png, 5.png and place them in nft-assets/images/.
3. Upload to Arweave Programmatically
Create src/uploadAssets.ts:
import { mplCore } from "@metaplex-foundation/mpl-core";
import { createGenericFile, keypairIdentity } from "@metaplex-foundation/umi";
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { irysUploader } from "@metaplex-foundation/umi-uploader-irys";
import fs from "fs";
import path from "path";
import { homedir } from "os";
async function uploadAssets() {
// Initialize Umi with Irys uploader
const umi = createUmi("https://api.devnet.solana.com")
.use(mplCore())
.use(irysUploader({
// mainnet: "https://node1.irys.xyz"
// devnet: "https://devnet.irys.xyz"
address: "https://devnet.irys.xyz"
}));
// Load wallet
const walletPath = path.join(homedir(), ".config/solana/id.json");
const secretKey = JSON.parse(fs.readFileSync(walletPath, "utf-8"));
const keypair = umi.eddsa.createKeypairFromSecretKey(new Uint8Array(secretKey));
umi.use(keypairIdentity(keypair));
console.log("Uploading assets to Arweave via Irys...\n");
const imageUris: string[] = [];
// Upload all images first
for (let i = 1; i <= 5; i++) {
// Read file from disk
const imageFile = fs.readFileSync(`./nft-assets/images/${i}.png`);
// Use createGenericFile to properly format for Umi
// Setting the correct MIME type is critical for Arweave display
const umiImageFile = createGenericFile(imageFile, `${i}.png`, {
tags: [{ name: "Content-Type", value: "image/png" }],
});
// Upload returns an array of URIs
const imageUri = await umi.uploader.upload([umiImageFile]).catch((err) => {
throw new Error(`Failed to upload image ${i}: ${err}`);
});
imageUris.push(imageUri[0]);
console.log(`✅ Image ${i} uploaded: ${imageUri[0]}`);
}
console.log("\nUploading metadata...\n");
// Upload metadata for each NFT
const metadataUris: string[] = [];
for (let i = 1; i <= 5; i++) {
const metadata = {
name: `Solana NFT #${i}`,
symbol: "SNFT",
description: `NFT #${i} from my Solana collection, featuring unique traits and artwork.`,
image: imageUris[i - 1],
external_url: "https://example.com",
attributes: [
{ trait_type: "Background", value: ["Blue", "Red", "Green", "Purple", "Gold"][i - 1] },
{ trait_type: "Rarity", value: i === 5 ? "Legendary" : i === 4 ? "Rare" : "Common" }
],
properties: {
files: [{ uri: imageUris[i - 1], type: "image/png" }],
category: "image",
creators: [{ address: keypair.publicKey.toString(), share: 100 }]
}
};
// uploadJson automatically converts object to JSON
const metadataUri = await umi.uploader.uploadJson(metadata).catch((err) => {
throw new Error(`Failed to upload metadata ${i}: ${err}`);
});
metadataUris.push(metadataUri);
console.log(`✅ Metadata ${i} uploaded: ${metadataUri}`);
}
// Upload collection metadata (uses first image as collection cover)
console.log("\nUploading collection metadata...");
const collectionMetadata = {
name: "My Solana Collection",
symbol: "MSC",
description: "A collection of unique NFTs on Solana.",
image: imageUris[0], // Use first image as collection cover
external_url: "https://example.com",
};
const collectionUri = await umi.uploader.uploadJson(collectionMetadata).catch((err) => {
throw new Error(`Failed to upload collection metadata: ${err}`);
});
console.log(`✅ Collection metadata uploaded: ${collectionUri}`);
console.log("\n=== Upload Complete ===");
console.log("Collection URI:", collectionUri);
console.log("NFT Metadata URIs:");
metadataUris.forEach((uri, i) => console.log(` NFT #${i + 1}: ${uri}`));
// Save URIs to a file for subsequent scripts
const outputData = {
collectionUri,
imageUris,
metadataUris,
uploadedAt: new Date().toISOString(),
};
fs.writeFileSync(
"./nft-assets/uploaded-uris.json",
JSON.stringify(outputData, null, 2)
);
console.log("\n✅ URIs saved to ./nft-assets/uploaded-uris.json");
return { collectionUri, metadataUris };
}
uploadAssets().catch(console.error);
Run the upload script:
npm run upload
The URIs are automatically saved to nft-assets/uploaded-uris.json for the next scripts to use.
4. Understanding Arweave URIs
After upload, your URIs look like:
https://gateway.irys.xyz/Fjnm2An...
These are permanent, immutable links served via Irys gateway. The data is stored on Arweave and can never change.
Alternative: Manual Upload via Irys Web
For quick testing without code, use the Irys web interface:
- Connect your Solana wallet
- Fund your account with SOL
- Upload files directly
- Copy the returned Arweave URIs
Creating an NFT Collection
Individual NFTs are useful, but most projects organize NFTs into collections. Metaplex Core has built-in collection support.
1. Create the Collection
Create src/createCollection.ts:
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import {
createCollectionV1,
mplCore,
fetchCollectionV1
} from "@metaplex-foundation/mpl-core";
import {
generateSigner,
keypairIdentity,
} from "@metaplex-foundation/umi";
import { readFileSync } from "fs";
import { homedir } from "os";
import path from "path";
async function main() {
// Initialize Umi
const umi = createUmi("https://api.devnet.solana.com").use(mplCore());
// Load wallet
const walletPath = path.join(homedir(), ".config/solana/id.json");
const secretKey = JSON.parse(readFileSync(walletPath, "utf-8"));
const keypair = umi.eddsa.createKeypairFromSecretKey(
new Uint8Array(secretKey)
);
umi.use(keypairIdentity(keypair));
console.log("Creating Collection...\n");
// Generate collection address
const collection = generateSigner(umi);
// Create the collection
const tx = await createCollectionV1(umi, {
collection,
name: "My Solana Collection",
uri: "https://arweave.net/YOUR_COLLECTION_METADATA_URI",
}).sendAndConfirm(umi);
console.log("✅ Collection Created!");
console.log("Collection Address:", collection.publicKey);
console.log("Transaction:", tx.signature);
// Fetch collection data
const fetchedCollection = await fetchCollectionV1(umi, collection.publicKey);
console.log("\nCollection Details:");
console.log("- Name:", fetchedCollection.name);
console.log("- URI:", fetchedCollection.uri);
// IMPORTANT: Save this address for minting NFTs into the collection
console.log("\n⚠️ Save this Collection Address for the next step!");
}
main().catch(console.error);
Collection metadata (collection.json) follows the same format as individual NFTs:
{
"name": "My Solana Collection",
"symbol": "MSC",
"description": "A collection of unique NFTs on Solana",
"image": "https://arweave.net/YOUR_COLLECTION_IMAGE_URI",
"external_url": "https://your-project.com"
}
2. Mint NFTs into the Collection
Create src/mintToCollection.ts:
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import {
create,
mplCore,
fetchAssetV1,
ruleSet
} from "@metaplex-foundation/mpl-core";
import {
generateSigner,
keypairIdentity,
publicKey
} from "@metaplex-foundation/umi";
import { readFileSync } from "fs";
import { homedir } from "os";
import path from "path";
async function main() {
const umi = createUmi("https://api.devnet.solana.com").use(mplCore());
// Load wallet
const walletPath = path.join(homedir(), ".config/solana/id.json");
const secretKey = JSON.parse(readFileSync(walletPath, "utf-8"));
const keypair = umi.eddsa.createKeypairFromSecretKey(
new Uint8Array(secretKey)
);
umi.use(keypairIdentity(keypair));
// REPLACE with your collection address from the previous step
const collectionAddress = publicKey("YOUR_COLLECTION_ADDRESS");
// REPLACE with your metadata URIs from uploadAssets.ts
const metadataUris = [
"https://arweave.net/YOUR_METADATA_URI_1",
"https://arweave.net/YOUR_METADATA_URI_2",
"https://arweave.net/YOUR_METADATA_URI_3",
"https://arweave.net/YOUR_METADATA_URI_4",
"https://arweave.net/YOUR_METADATA_URI_5",
];
console.log("Minting NFTs to collection...\n");
// Mint 5 NFTs
for (let i = 1; i <= 5; i++) {
const asset = generateSigner(umi);
const tx = await create(umi, {
asset,
name: `Solana NFT #${i}`,
uri: metadataUris[i - 1],
collection: collectionAddress,
plugins: [
{
type: "Royalties",
basisPoints: 500, // 5%
creators: [
{
address: keypair.publicKey,
percentage: 100,
},
],
ruleSet: ruleSet("None"),
},
],
}).sendAndConfirm(umi);
console.log(`✅ NFT #${i} minted!`);
console.log(` Address: ${asset.publicKey}`);
console.log(` Tx: ${tx.signature}\n`);
// Small delay to avoid rate limits
await new Promise(resolve => setTimeout(resolve, 1000));
}
console.log("All NFTs minted successfully!");
}
main().catch(console.error);
Key difference from standalone NFTs: The collection parameter links the NFT to your collection, enabling:
- Collection-level filtering on marketplaces
- Verified collection badges
- Shared royalty settings (via collection-level plugins)
Writing Tests
Testing Solana programs differs from testing Ethereum contracts. You're not testing program logic--Metaplex handles that. You're testing that your transactions are built correctly and achieve the expected outcomes.
Unit Tests with Vitest
Install testing dependencies:
npm install -D vitest
Add to package.json scripts:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
Create src/tests/nft.test.ts:
import { describe, it, expect, beforeAll } from "vitest";
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import {
create,
createCollectionV1,
fetchAssetV1,
fetchCollectionV1,
mplCore,
transferV1,
burnV1,
ruleSet,
} from "@metaplex-foundation/mpl-core";
import {
generateSigner,
keypairIdentity,
} from "@metaplex-foundation/umi";
import { readFileSync } from "fs";
import { homedir } from "os";
import path from "path";
// Helper to wait for RPC indexing
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Helper to fetch asset with retries (handles RPC indexing delays)
async function fetchAssetWithRetry(
umi: any,
publicKey: any,
maxRetries = 5,
delayMs = 2000
) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fetchAssetV1(umi, publicKey);
} catch (error: any) {
if (
error.name === "AccountNotFoundError" &&
i < maxRetries - 1
) {
console.log(`Retry ${i + 1}/${maxRetries}: waiting for RPC indexing...`);
await sleep(delayMs);
continue;
}
throw error;
}
}
}
describe("Solana NFT Tests", () => {
let umi: ReturnType<typeof createUmi>;
let ownerKeypair: any;
beforeAll(async () => {
// Initialize Umi for devnet
umi = createUmi("https://api.devnet.solana.com").use(mplCore());
// Load wallet
const walletPath = path.join(homedir(), ".config/solana/id.json");
const secretKey = JSON.parse(readFileSync(walletPath, "utf-8"));
ownerKeypair = umi.eddsa.createKeypairFromSecretKey(
new Uint8Array(secretKey)
);
umi.use(keypairIdentity(ownerKeypair));
});
it("should create an NFT with correct metadata", async () => {
const asset = generateSigner(umi);
const name = "Test NFT";
const uri = "https://example.com/test.json";
await create(umi, {
asset,
name,
uri,
}).sendAndConfirm(umi, { confirm: { commitment: "finalized" } });
const fetchedAsset = await fetchAssetWithRetry(umi, asset.publicKey);
expect(fetchedAsset!.name).toBe(name);
expect(fetchedAsset!.uri).toBe(uri);
expect(fetchedAsset!.owner.toString()).toBe(
ownerKeypair.publicKey.toString()
);
}, 60000);
it("should create a collection and add NFT to it", async () => {
// Create collection
const collection = generateSigner(umi);
await createCollectionV1(umi, {
collection,
name: "Test Collection",
uri: "https://example.com/collection.json",
}).sendAndConfirm(umi, { confirm: { commitment: "finalized" } });
// Wait for collection to be indexed
await sleep(3000);
// Create NFT in collection (pass collection signer to authorize adding)
const asset = generateSigner(umi);
await create(umi, {
asset,
name: "Collection NFT",
uri: "https://example.com/nft.json",
collection,
}).sendAndConfirm(umi, { confirm: { commitment: "finalized" } });
const fetchedAsset = await fetchAssetWithRetry(umi, asset.publicKey);
// Verify collection assignment
expect(fetchedAsset!.updateAuthority.type).toBe("Collection");
}, 90000);
it("should create NFT with royalties plugin", async () => {
const asset = generateSigner(umi);
await create(umi, {
asset,
name: "Royalty NFT",
uri: "https://example.com/royalty.json",
plugins: [
{
type: "Royalties",
basisPoints: 500, // 5%
creators: [
{
address: ownerKeypair.publicKey,
percentage: 100,
},
],
ruleSet: ruleSet("None"),
},
],
}).sendAndConfirm(umi, { confirm: { commitment: "finalized" } });
const fetchedAsset = await fetchAssetWithRetry(umi, asset.publicKey);
// Verify royalties plugin exists
const royaltiesPlugin = fetchedAsset!.royalties;
expect(royaltiesPlugin).toBeDefined();
expect(royaltiesPlugin?.basisPoints).toBe(500);
}, 60000);
it("should transfer NFT to new owner", async () => {
// Create NFT
const asset = generateSigner(umi);
await create(umi, {
asset,
name: "Transfer Test NFT",
uri: "https://example.com/transfer.json",
}).sendAndConfirm(umi, { confirm: { commitment: "finalized" } });
// Wait for creation to be fully indexed
await sleep(2000);
// Generate new owner
const newOwner = generateSigner(umi);
// Transfer NFT
await transferV1(umi, {
asset: asset.publicKey,
newOwner: newOwner.publicKey,
}).sendAndConfirm(umi, { confirm: { commitment: "finalized" } });
// Wait for transfer to be indexed
await sleep(2000);
// Verify new ownership
const fetchedAsset = await fetchAssetWithRetry(umi, asset.publicKey);
expect(fetchedAsset!.owner.toString()).toBe(newOwner.publicKey.toString());
}, 60000);
it("should burn an NFT", async () => {
// Create NFT
const asset = generateSigner(umi);
await create(umi, {
asset,
name: "Burn Test NFT",
uri: "https://example.com/burn.json",
}).sendAndConfirm(umi, { confirm: { commitment: "finalized" } });
// Wait for creation to be fully indexed
await sleep(3000);
// Burn the NFT
await burnV1(umi, {
asset: asset.publicKey,
}).sendAndConfirm(umi, { confirm: { commitment: "finalized" } });
// Wait for burn to propagate
await sleep(2000);
// Verify NFT no longer exists
try {
await fetchAssetV1(umi, asset.publicKey);
expect.fail("NFT should not exist after burning");
} catch (error) {
// Expected: account doesn't exist
expect(error).toBeDefined();
}
}, 60000);
it("should reject transfer from non-owner", async () => {
// Create NFT
const asset = generateSigner(umi);
await create(umi, {
asset,
name: "Auth Test NFT",
uri: "https://example.com/auth.json",
}).sendAndConfirm(umi, { confirm: { commitment: "finalized" } });
// Wait for creation to be fully indexed
await sleep(2000);
// Create a different identity (attacker)
const attackerKeypair = umi.eddsa.generateKeypair();
// Switch to attacker identity
const attackerUmi = createUmi("https://api.devnet.solana.com")
.use(mplCore())
.use(keypairIdentity(attackerKeypair));
// Attempt transfer (should fail)
try {
await transferV1(attackerUmi, {
asset: asset.publicKey,
newOwner: attackerKeypair.publicKey,
}).sendAndConfirm(attackerUmi);
expect.fail("Transfer should fail for non-owner");
} catch (error) {
// Expected: unauthorized
expect(error).toBeDefined();
}
}, 60000);
});
Run tests:
npm test
Important notes about devnet testing:
- Tests use
commitment: "finalized"to ensure transactions are fully confirmed - The
fetchAssetWithRetryhelper handles RPC indexing delays (devnet can be slow) - Sleep delays between operations give the RPC time to index new accounts
- Timeouts are set to 60-90 seconds to accommodate network latency
For faster, more reliable tests in production, use a local validator:
solana-test-validator
Then change the RPC URL to http://localhost:8899.
Running Your Scripts
Time to mint your collection.
1. Add Script Commands
Update package.json:
{
"scripts": {
"create-nft": "ts-node src/createNft.ts",
"upload": "ts-node src/uploadAssets.ts",
"create-collection": "ts-node src/createCollection.ts",
"mint": "ts-node src/mintToCollection.ts",
"transfer": "ts-node src/transfer.ts",
"burn": "ts-node src/burn.ts",
"update": "ts-node src/update.ts",
"test": "vitest run"
}
}
2. The Complete Workflow
Here's the correct order of operations:
# Step 1: Upload everything to Arweave (images + NFT metadata + collection metadata)
npm run upload
# Step 2: Create collection (reads collectionUri from saved file)
npm run create-collection
# Step 3: Mint all NFTs to the collection
npm run mint
Important: Each script saves its output to nft-assets/*.json files that subsequent scripts read automatically.
3. Upload Assets to Arweave
First, ensure your images are in nft-assets/images/ (named 1.png, 2.png, etc.), then:
npm run upload
This uploads:
- All images to Arweave
- NFT metadata for each image
- Collection metadata
Output is saved to nft-assets/uploaded-uris.json.
4. Create Your Collection
npm run create-collection
The script reads collectionUri from the uploaded URIs file automatically. Output is saved to nft-assets/collection.json.
Note: You may see a warning about "RPC indexing delay"--this is normal on devnet. The collection was created successfully, but the fetch happened before the RPC node indexed the new account.
5. Mint NFTs to Collection
npm run mint
The script reads both the collection address and metadata URIs from the saved files. Each NFT is minted with 5% royalties.
Output is saved to nft-assets/minted-assets.json.
Viewing Your NFTs
On Solana Explorers
View your NFT on Solscan (devnet):
https://solscan.io/token/YOUR_NFT_ADDRESS?cluster=devnet
Or Solana Explorer:
https://explorer.solana.com/address/YOUR_NFT_ADDRESS?cluster=devnet
The explorer shows:
- Token name and symbol
- Current owner
- Metadata URI
- Transaction history
In Phantom Wallet
- Open Phantom and ensure you're on Devnet (Settings → Developer Settings → Change Network)
- Navigate to the Collectibles tab
- Your NFTs should appear automatically
If they don't show immediately:
- Click the refresh icon
- Wait a few minutes for indexing
- Verify the NFT owner matches your Phantom address
On Magic Eden (Devnet)
Magic Eden has limited devnet support, but you can check:
https://magiceden.io/item-details/devnet/YOUR_NFT_ADDRESS
For mainnet NFTs, they appear automatically once indexed.
Troubleshooting: Common Issues
AccountNotFoundError after creating NFT/Collection
AccountNotFoundError: The account of type [AssetV1] was not found...
This is normal on devnet. The NFT/collection was created successfully, but the RPC node hasn't indexed it yet when the script tries to fetch details. The transaction succeeded--check the explorer link to verify.
Solution: Our scripts include a 2-second delay and try/catch handling. If you still see this, the asset was created--just wait and check the explorer.
"Run 'npm run upload' first" error
The scripts chain together. You must run them in order:
npm run upload→ Createsuploaded-uris.jsonnpm run create-collection→ Reads URIs, createscollection.jsonnpm run mint→ Reads both files, mints NFTs
Insufficient SOL
Upload and minting require SOL. For devnet:
solana airdrop 2
If you hit rate limits, wait a minute or use the Solana Faucet.
Wallet not found
The scripts look for ~/.config/solana/id.json by default. If your wallet is elsewhere:
solana config get # Check current keypair path
Then update the walletPath in the scripts.
Transferring and Managing NFTs
Transfer an NFT
Create src/transfer.ts:
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { transferV1, mplCore } from "@metaplex-foundation/mpl-core";
import { keypairIdentity, publicKey } from "@metaplex-foundation/umi";
import { readFileSync } from "fs";
import { homedir } from "os";
import path from "path";
async function main() {
const umi = createUmi("https://api.devnet.solana.com").use(mplCore());
// Load wallet
const walletPath = path.join(homedir(), ".config/solana/id.json");
const secretKey = JSON.parse(readFileSync(walletPath, "utf-8"));
const keypair = umi.eddsa.createKeypairFromSecretKey(
new Uint8Array(secretKey)
);
umi.use(keypairIdentity(keypair));
// REPLACE these values
const nftAddress = publicKey("YOUR_NFT_ADDRESS");
const recipientAddress = publicKey("RECIPIENT_WALLET_ADDRESS");
console.log("Transferring NFT...");
const tx = await transferV1(umi, {
asset: nftAddress,
newOwner: recipientAddress,
}).sendAndConfirm(umi);
console.log("✅ NFT Transferred!");
console.log("Transaction:", tx.signature);
}
main().catch(console.error);
Burn an NFT
Create src/burn.ts:
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { burnV1, mplCore } from "@metaplex-foundation/mpl-core";
import { keypairIdentity, publicKey } from "@metaplex-foundation/umi";
import { readFileSync } from "fs";
import { homedir } from "os";
import path from "path";
async function main() {
const umi = createUmi("https://api.devnet.solana.com").use(mplCore());
// Load wallet
const walletPath = path.join(homedir(), ".config/solana/id.json");
const secretKey = JSON.parse(readFileSync(walletPath, "utf-8"));
const keypair = umi.eddsa.createKeypairFromSecretKey(
new Uint8Array(secretKey)
);
umi.use(keypairIdentity(keypair));
// REPLACE with your NFT address
const nftAddress = publicKey("YOUR_NFT_ADDRESS");
console.log("Burning NFT...");
const tx = await burnV1(umi, {
asset: nftAddress,
}).sendAndConfirm(umi);
console.log("✅ NFT Burned!");
console.log("Transaction:", tx.signature);
console.log("Account rent returned to your wallet.");
}
main().catch(console.error);
Note: Burning returns the account rent to your wallet. After transaction fees, you'll receive approximately ~0.0015 SOL back.
Update NFT Metadata
Create src/update.ts:
import { createUmi } from "@metaplex-foundation/umi-bundle-defaults";
import { updateV1, mplCore, fetchAssetV1 } from "@metaplex-foundation/mpl-core";
import { keypairIdentity, publicKey } from "@metaplex-foundation/umi";
import { readFileSync } from "fs";
import { homedir } from "os";
import path from "path";
async function main() {
const umi = createUmi("https://api.devnet.solana.com").use(mplCore());
// Load wallet
const walletPath = path.join(homedir(), ".config/solana/id.json");
const secretKey = JSON.parse(readFileSync(walletPath, "utf-8"));
const keypair = umi.eddsa.createKeypairFromSecretKey(
new Uint8Array(secretKey)
);
umi.use(keypairIdentity(keypair));
// REPLACE with your NFT address
const nftAddress = publicKey("YOUR_NFT_ADDRESS");
// Fetch current asset
const asset = await fetchAssetV1(umi, nftAddress);
console.log("Current name:", asset.name);
// Update the NFT
const tx = await updateV1(umi, {
asset: nftAddress,
newName: "Updated NFT Name",
newUri: "https://gateway.irys.xyz/YOUR_NEW_METADATA_URI",
}).sendAndConfirm(umi);
console.log("✅ NFT Updated!");
console.log("Transaction:", tx.signature);
// Verify update
const updatedAsset = await fetchAssetV1(umi, nftAddress);
console.log("New name:", updatedAsset.name);
}
main().catch(console.error);
Conclusion
You now have a working NFT collection on Solana. Here's what you built:
- Solana dev environment: CLI, wallet, devnet SOL
- Metaplex Core NFTs: the cheapest standard, with royalty plugins
- Arweave metadata: permanent storage via Irys
- Collection structure: organized NFTs with verified badges
- Tests: verify operations before mainnet
- Management scripts: transfers, burns, updates
Solana vs. Ethereum: What You've Learned
| Aspect | Ethereum (ERC-721) | Solana (Metaplex) |
|---|---|---|
| Mint cost | $50-150+ | ~$0.01-0.02 |
| Custom logic | Write Solidity contracts | Use existing programs |
| Data storage | Contract state | Separate accounts |
| Ecosystem integration | Varies by contract | Universal via Metaplex |
| Development time | Longer (contract dev) | Shorter (call programs) |
What's Missing?
Production projects typically add:
- Candy Machine: Metaplex's tool for large-scale mints with reveal mechanics, phases, and guard rails
- Frontend dApp: A minting website using React/Next.js with wallet adapters
- Compressed NFTs (cNFTs): For massive collections (100k+) at fractions of a cent per mint
- On-chain attributes: Using the Attributes plugin for game items or evolving NFTs
- Staking/Utility: Custom programs that interact with your NFTs
Next Steps
Build a minting dApp: Use @solana/wallet-adapter with React to create a frontend where users connect their wallets and mint NFTs.
Explore Candy Machine: For large collections with public sales, phases, and allowlists, Metaplex's Candy Machine handles the complexity.
Try compressed NFTs: For gaming or mass distribution, Bubblegum's cNFTs let you mint millions of NFTs for dollars, not thousands.
Study successful projects: Read the on-chain data of Mad Lads, Tensorians, or DeGods using Solscan. You'll recognize the Metaplex structures you just learned.
Deploy to mainnet: When ready, change your RPC URL to mainnet-beta and ensure your wallet has real SOL. The code is identical--just the network changes.