Mint an NFT Collection on Cardano
Create native NFT assets on Cardano using Mesh SDK and Bun. Learn CIP-25 metadata, native scripts, and transaction building.
Overview
In this guide, you mint an NFT collection on Cardano using native scripts. Cardano's native multi-asset system allows minting tokens without smart contracts, making them simpler and cheaper to create. Token metadata follows the CIP-25 standard.
What you will build
- A minting script that creates multiple NFTs in one transaction
- CIP-25 compliant metadata for marketplace compatibility
- A time-locked minting policy that expires after a set slot
What you will learn
- How Cardano native tokens work without smart contracts
- Building minting transactions with Mesh SDK
- Implementing CIP-25 metadata standards
Prerequisites
- Bun installed
- A Blockfrost API key (get one free)
- Basic TypeScript knowledge
Time to complete
45 minutes
Quick Start
If you want to start with a working example:
git clone https://github.com/MeshJS/examples
cd examples/minting
bun installAdd your credentials to .env and run bun run index.ts.
Step-by-Step Guide
Step 1: Set up the project
Install Bun if you have not already:
curl -fsSL https://bun.sh/install | bashCreate and initialize a new project:
mkdir nft-collection
cd nft-collection
bun init -y
bun add @meshsdk/coreWhat to expect: A new project with package.json and @meshsdk/core installed.
Step 2: Generate a wallet
Create scripts/generate-wallet.ts:
import { MeshCardanoHeadlessWallet, AddressType } from "@meshsdk/wallet";
import { generateMnemonic } from "@meshsdk/core";
// Generate a new mnemonic phrase
const words = generateMnemonic(256);
console.log("Mnemonic phrase:");
console.log(words.join(" "));
console.log("");
// Create wallet from mnemonic
const wallet = await MeshCardanoHeadlessWallet.fromMnemonic({
mnemonic: words,
networkId: 0, // 0 = testnet
walletAddressType: AddressType.Base,
});
const address = await wallet.getChangeAddressBech32();
console.log("Wallet address:");
console.log(address);Run the script:
bun run scripts/generate-wallet.tsWhat to expect: A 24-word mnemonic phrase and a wallet address starting with addr_test1.
Step 3: Fund your wallet
- Copy the wallet address from the previous step
- Go to the Cardano testnet faucet
- Paste your address and request test ADA
- Wait 1-2 minutes for the transaction to confirm
What to expect: Your wallet receives test ADA (usually 10,000 tADA).
Step 4: Configure environment
Create a .env file:
MNEMONIC=your twenty four word mnemonic phrase goes here
BLOCKFROST_KEY=your_blockfrost_preprod_api_keyWhat to expect: Your credentials are stored securely and not committed to git.
Step 5: Create the minting script
Create index.ts:
import {
MeshTxBuilder,
BlockfrostProvider,
ForgeScript,
resolveScriptHash,
stringToHex,
deserializeAddress,
} from "@meshsdk/core";
import { MeshCardanoHeadlessWallet, AddressType } from "@meshsdk/wallet";
import type { NativeScript, AssetMetadata } from "@meshsdk/core";
// Load environment variables
const BLOCKFROST_KEY = process.env.BLOCKFROST_KEY!;
const MNEMONIC = process.env.MNEMONIC!;
if (!BLOCKFROST_KEY || !MNEMONIC) {
throw new Error("Missing BLOCKFROST_KEY or MNEMONIC in environment");
}
// Initialize provider and wallet
const provider = new BlockfrostProvider(BLOCKFROST_KEY);
const wallet = await MeshCardanoHeadlessWallet.fromMnemonic({
mnemonic: MNEMONIC.split(" "),
networkId: 0,
walletAddressType: AddressType.Base,
fetcher: provider,
submitter: provider,
});
const address = await wallet.getChangeAddressBech32();
const utxos = await wallet.getUtxosMesh();
const { pubKeyHash } = deserializeAddress(address);
console.log("Wallet address:", address);
console.log("Available UTxOs:", utxos.length);
if (utxos.length === 0) {
throw new Error("No UTxOs available. Fund the wallet first.");
}What to expect: The script initializes the wallet and provider.
Step 6: Define the native script
Add the native script (minting policy) to index.ts:
// Define the native script (minting policy)
// This policy requires your signature AND expires at a specific slot
const LOCK_SLOT = 90000000; // Adjust this to a future slot on preprod
const nativeScript: NativeScript = {
type: "all",
scripts: [
{
type: "before",
slot: LOCK_SLOT.toString(),
},
{
type: "sig",
keyHash: pubKeyHash,
},
],
};
const forgingScript = ForgeScript.fromNativeScript(nativeScript);
const policyId = resolveScriptHash(forgingScript);
console.log("Policy ID:", policyId);The native script enforces two conditions:
before- Minting is only valid before the specified slotsig- The transaction must be signed by your wallet
What to expect: A deterministic policy ID derived from the script.
Step 7: Create the metadata function
Add the CIP-25 metadata generator:
type NFTMetadata = {
name: string;
image: string;
mediaType: string;
description?: string;
attributes?: Record<string, unknown>;
files?: Array<{
mediaType: string;
name: string;
src: string;
}>;
};
function createMetadata(
name: string,
imageIpfs: string,
attributes?: Record<string, unknown>
): NFTMetadata {
return {
name,
image: imageIpfs,
mediaType: "image/png",
description: `${name} from my NFT collection`,
attributes,
files: [
{
mediaType: "image/png",
name,
src: imageIpfs,
},
],
};
}What to expect: A function that generates CIP-25 compliant metadata.
Step 8: Build the minting transaction
Add the transaction building logic:
// Prepare metadata for all NFTs
const collectionMetadata: {
[policyId: string]: { [assetName: string]: NFTMetadata };
} = { [policyId]: {} };
const txBuilder = new MeshTxBuilder({ fetcher: provider });
// Mint 9 NFTs
const COLLECTION_SIZE = 9;
const IMAGE_IPFS = "ipfs://QmPS4PBvpGc2z6Dd6JdYqfHrKnURjtRGPTJWdhnAXNA8bQ";
for (let i = 1; i <= COLLECTION_SIZE; i++) {
const tokenName = `MyNFT #${i}`;
const tokenNameHex = stringToHex(tokenName);
// Add mint instruction
txBuilder
.mint("1", policyId, tokenNameHex)
.mintingScript(forgingScript);
// Add metadata
collectionMetadata[policyId][tokenName] = createMetadata(
tokenName,
IMAGE_IPFS,
{ edition: i, rarity: i <= 3 ? "rare" : "common" }
);
}
// Complete and submit the transaction
const unsignedTx = await txBuilder
.metadataValue(721, collectionMetadata)
.changeAddress(address)
.invalidHereafter(LOCK_SLOT)
.selectUtxosFrom(utxos)
.complete();
const signedTx = await wallet.signTx(unsignedTx, false);
const txHash = await wallet.submitTx(signedTx);
console.log("Transaction submitted!");
console.log("Transaction hash:", txHash);
console.log(`View on explorer: https://preprod.cardanoscan.io/transaction/${txHash}`);What to expect: The transaction mints 9 NFTs and sends them to your wallet.
Step 9: Run the minting script
Execute the complete script:
bun run index.tsWhat to expect: Console output showing the transaction hash. After 1-2 minutes, your NFTs appear in your wallet.
Complete Example
Here is the complete index.ts:
import {
MeshTxBuilder,
BlockfrostProvider,
ForgeScript,
resolveScriptHash,
stringToHex,
deserializeAddress,
} from "@meshsdk/core";
import { MeshCardanoHeadlessWallet, AddressType } from "@meshsdk/wallet";
import type { NativeScript } from "@meshsdk/core";
// Configuration
const BLOCKFROST_KEY = process.env.BLOCKFROST_KEY!;
const MNEMONIC = process.env.MNEMONIC!;
const LOCK_SLOT = 90000000;
const COLLECTION_SIZE = 9;
const IMAGE_IPFS = "ipfs://QmPS4PBvpGc2z6Dd6JdYqfHrKnURjtRGPTJWdhnAXNA8bQ";
if (!BLOCKFROST_KEY || !MNEMONIC) {
throw new Error("Missing environment variables");
}
// Types
type NFTMetadata = {
name: string;
image: string;
mediaType: string;
description?: string;
attributes?: Record<string, unknown>;
files?: Array<{ mediaType: string; name: string; src: string }>;
};
function createMetadata(
name: string,
imageIpfs: string,
attributes?: Record<string, unknown>
): NFTMetadata {
return {
name,
image: imageIpfs,
mediaType: "image/png",
description: `${name} from my NFT collection`,
attributes,
files: [{ mediaType: "image/png", name, src: imageIpfs }],
};
}
async function main() {
// Initialize
const provider = new BlockfrostProvider(BLOCKFROST_KEY);
const wallet = await MeshCardanoHeadlessWallet.fromMnemonic({
mnemonic: MNEMONIC.split(" "),
networkId: 0,
walletAddressType: AddressType.Base,
fetcher: provider,
submitter: provider,
});
const address = await wallet.getChangeAddressBech32();
const utxos = await wallet.getUtxosMesh();
const { pubKeyHash } = deserializeAddress(address);
console.log("Wallet:", address);
console.log("UTxOs:", utxos.length);
// Create minting policy
const nativeScript: NativeScript = {
type: "all",
scripts: [
{ type: "before", slot: LOCK_SLOT.toString() },
{ type: "sig", keyHash: pubKeyHash },
],
};
const forgingScript = ForgeScript.fromNativeScript(nativeScript);
const policyId = resolveScriptHash(forgingScript);
console.log("Policy ID:", policyId);
// Build transaction
const collectionMetadata: Record<string, Record<string, NFTMetadata>> = {
[policyId]: {},
};
const txBuilder = new MeshTxBuilder({ fetcher: provider });
for (let i = 1; i <= COLLECTION_SIZE; i++) {
const tokenName = `MyNFT #${i}`;
const tokenNameHex = stringToHex(tokenName);
txBuilder.mint("1", policyId, tokenNameHex).mintingScript(forgingScript);
collectionMetadata[policyId][tokenName] = createMetadata(tokenName, IMAGE_IPFS, {
edition: i,
rarity: i <= 3 ? "rare" : "common",
});
}
const unsignedTx = await txBuilder
.metadataValue(721, collectionMetadata)
.changeAddress(address)
.invalidHereafter(LOCK_SLOT)
.selectUtxosFrom(utxos)
.complete();
const signedTx = await wallet.signTx(unsignedTx, false);
const txHash = await wallet.submitTx(signedTx);
console.log("Success! TX:", txHash);
console.log(`https://preprod.cardanoscan.io/transaction/${txHash}`);
}
main().catch(console.error);Next Steps
- Add unique images - Upload artwork to IPFS and use unique hashes per NFT
- Expand metadata - Add more attributes following the CIP-25 specification
- Deploy to mainnet - Change
networkIdto1and use a mainnet Blockfrost key - Multi-signature minting - Let users pay for minting
Troubleshooting
Slot has already passed
Cause: The LOCK_SLOT value is in the past.
Solution: Check the current slot on preprod.cardanoscan.io and set LOCK_SLOT to a value at least a few hours in the future.
Transaction too large
Cause: Minting too many NFTs in one transaction.
Solution: Reduce COLLECTION_SIZE or split into multiple transactions. A single transaction can typically handle 20-50 mints depending on metadata size.
Invalid metadata
Cause: Metadata does not follow CIP-25 format.
Solution: Ensure the metadata structure matches CIP-25 requirements. The top-level key must be the policy ID, and each asset must have name and image fields.
Insufficient funds
Cause: Not enough ADA to cover the transaction fee and min-UTXO requirements.
Solution: Request more test ADA from the faucet. Each NFT requires approximately 1.5 ADA for the min-UTXO deposit.
References
- CIP-25: Media NFT Metadata Standard - The Cardano standard for NFT metadata used in this guide
- Cardano Developer Portal: Native Tokens - Official documentation on Cardano's native multi-asset system
- Cardano Developer Portal - Official Cardano development resources and tools
Related Links
- CIP-25 Metadata Standard - Official NFT metadata specification
- Cardano Testnet Faucet - Get free test ADA
- Blockfrost - API provider for Cardano
- Server-Side Minting - Mint and distribute tokens
- Minting API Reference - Advanced minting options