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. Native assets on Cardano do not require smart contracts, making them simpler and cheaper to create.
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.
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