Before this week, I thought minting an NFT on Solana meant learning Metaplex. It turns out you can mint a full NFT — with metadata, a collection, and live mutation — using just the Token Extensions program and about 50 lines of Node.js.
Here's what I actually built, what surprised me, and what I'd do differently.
The Mental Model: What Is a Solana NFT?
Strip away the marketplaces and the profile pictures and an NFT on Solana
is just a token mint with three properties:
- Supply = 1 — only one copy exists
- Decimals = 0 — it can't be split into fractions
- Mint authority disabled — nobody can ever create a second copy
That's it. The same SPL Token program that handles fungible tokens handles
NFTs. The only difference is the configuration.
With the Token Extensions Program (Token-2022), you can go further. You attach extensions to the mint at creation time that add behavior at the protocol level. For NFTs, the relevant ones are:
| Extension | What it does |
|-----------|-------------|
| MetadataPointer | Points to the account that holds the token's metadata |
| TokenMetadata | Stores name, symbol, URI, and custom fields on the mint itself |
| GroupPointer | Marks a mint as a collection |
| GroupMemberPointer | Links a mint to a parent collection |
No Metaplex account. No companion program. The metadata lives on the mint.
What I Built: Four Days, One NFT Arc
Day 1: The bare 1-of-1
I started with the simplest possible NFT — no metadata, no name, just the three properties above.
// Create a mint with 0 decimals using original SPL Token
const createMintTx = new Transaction().add(
SystemProgram.createAccount({
fromPubkey: wallet.publicKey,
newAccountPubkey: mint,
space: 82,
lamports: mintLamports,
programId: TOKEN_PROGRAM_ID,
}),
createInitializeMintInstruction(
mint,
0, // decimals
wallet.publicKey,
wallet.publicKey,
TOKEN_PROGRAM_ID
)
);
// After minting 1 token, permanently disable the mint authority
await sendAndConfirmTransaction(connection, new Transaction().add(
createSetAuthorityInstruction(
mint,
wallet.publicKey,
AuthorityType.MintTokens,
null, // null = disable forever
[],
TOKEN_PROGRAM_ID
)
), [wallet]);
Opening this in Solana Explorer showed "Unknown Token" — no name, no image, just an address. That blankness was the point. It proved the NFT-ness has nothing to do with the presentation layer.
Day 2: Stamping metadata with Token-2022
The next day I rebuilt it using Token-2022 with the MetadataPointer and TokenMetadata extensions. Now the name, symbol, and URI live on the mint account itself.
// Key instruction: write metadata directly onto the mint
createInitializeInstruction({
programId: TOKEN_2022_PROGRAM_ID,
metadata: mint, // metadata lives on the mint itself
updateAuthority: wallet.publicKey,
mint,
mintAuthority: wallet.publicKey,
name: "First Light",
symbol: "LIGHT",
uri: "https://gist.githubusercontent.com/.../metadata.json",
})
After this transaction confirmed, the Explorer rendered the token with its name, symbol, and image fetched from the URI. It looked like a real NFT because it was one.
Day 3: Wrapping it in a collection
Collections on Token-2022 use two extension pairs:
- The collection mint gets
GroupPointer+TokenGroup - Each member NFT gets
GroupMemberPointer+TokenGroupMember
The member extension stores a pointer back to the collection. This is exactly a foreign key in a relational database — the collection is the parent table, and each NFT is a child row.
After minting two member NFTs, the collection mint's Token Group showed size: 2, maxSize: 3. Any wallet or marketplace can verify membership by reading that pointer without trusting any off-chain index.
Day 4: Mutating metadata live
The metadata extension is mutable as long as you hold the update authority. This turned out to be the most interesting day.
// Rename the NFT
await sendAndConfirmTransaction(connection, new Transaction().add(
createUpdateFieldInstruction({
programId: TOKEN_2022_PROGRAM_ID,
metadata: new PublicKey(NFT_MINT),
updateAuthority: wallet.publicKey,
field: "name",
value: "Field Notes",
})
), [wallet]);
// Add a custom key-value pair
await updateField(connection, wallet, NFT_MINT, "edition", "field-test-1");
// Remove it
await sendAndConfirmTransaction(connection, new Transaction().add(
createRemoveKeyInstruction({
programId: TOKEN_2022_PROGRAM_ID,
metadata: new PublicKey(NFT_MINT),
updateAuthority: wallet.publicKey,
key: "rarity",
idempotent: true,
})
), [wallet]);
Each of those is a single transaction. The on-chain name changed instantly. The image in the wallet lagged for a while — it was cached. That gap between the on-chain layer (instant) and the off-chain image layer (cached) is one of the more practical things I learned this week.
The Surprising Parts
Extensions are declared at creation time and can't be added later.
This is the Solana version of "schema decisions are forever." If you create a mint and forget to include GroupMemberPointer, you can't patch it in.
You have to mint a new token. This forces upfront design in a way that felt restrictive at first but is actually good discipline.
The instruction order inside a transaction matters.
Extension initializers must run before createInitializeMintInstruction. I got a cryptic InvalidAccountData error the first time I got this wrong.
Once I understood that extensions configure the account before the mint is initialized, the order made sense.
Windows doesn't have the spl-token CLI.
I did every challenge in Node.js using @solana/web3.js and
@solana/spl-token directly. This was harder than using the CLI but taught me more — I had to understand the actual instructions rather than just running commands.
The fungible vs NFT comparison is just numbers.
Running an audit script against my Day 30 fungible token alongside my NFTs made this concrete:
| Property | Fungible (Day 30) | NFT (Day 44) |
|---|---|---|
| Supply | 1,000,000,000 base units | 1 |
| Decimals | 6 | 0 |
| Mint authority | Active | Disabled |
| Extensions | None | MetadataPointer, TokenMetadata |
| Account size | 82 bytes | 469 bytes |
Same program. Same instruction set. Just different configuration.
What I'd Build Next
The natural next step is exploring Metaplex Core — the higher-level NFT standard that most production projects on Solana use. Now that I understand what Token Extensions give you natively, I can actually evaluate what Metaplex adds (royalty enforcement, collection verification, creator splits) versus what you get for free at the protocol level.
I'm also curious about Arweave and IPFS for URI hosting. Right now my NFT points at a GitHub Gist. That's fine for devnet experiments, but the whole point of an immutable on-chain pointer is that the thing it points at should also be permanent. A mutable Gist defeats that.
Resources
- Token Extensions overview — the canonical reference
- Metadata Pointer and Token Metadata extensions — what I used for on-chain metadata
- Token Groups and Members — how collections work
- Solana Explorer (devnet) — where I verified every step
- Metaplex Core — the production NFT standard worth knowing
This post is part of #100DaysOfSolana. I'm building every day on devnet
— follow along or jump in any day.
Top comments (0)