Minting Pixels On-Chain: Building and Deploying Your Own SVG NFT Smart Contract
A Step-by-Step Guide to Writing and Deploying your Onchain (SVG) NFT Smart Contract

I love crafting beautiful web experiences and writing efficient smart contracts. I'm currently exploring Zero-Knowledge proofs, as well as Functional, Systems, Concurrent, and Scripting Programming Languages (Rust, Erlang, and Python).
Hey there, blockchain enthusiasts! Let’s dive into something exciting: building and deploying your own SVG NFT smart contract on-chain. Imagine crafting digital art that’s fully self-contained, tamper-proof, and lives forever on the blockchain, with no external dependencies, just pure decentralized creativity. That’s what we’re exploring today, and I’ll guide you through it step-by-step.
Some Terms Used
Smart Contract: Code on the blockchain that runs automatically based on set rules.
SVG (Scalable Vector Graphics): A vector image format that scales without losing quality.
NFT (Non-Fungible Token): A unique blockchain token, often for art or collectibles.
ERC-721: The standard for creating unique NFTs on EVM blockchains.
Base64: An encoding method that converts binary data (like SVG images) into a text string, often used in NFTs to embed metadata and images in the token URI.
Rootstock (RSK): A Bitcoin sidechain with smart contract support, tied to Bitcoin’s security.
RBTC: Rootstock’s token, pegged 1:1 to Bitcoin, used for gas and transactions.
tokenURI: An ERC-721 function that returns an NFT’s metadata, like its SVG image.
What Are On-Chain SVG Smart Contracts?
On-chain SVG smart contracts are smart contracts that store Scalable Vector Graphics (SVG) images directly on the blockchain. Unlike traditional NFTs, where image data often sits off-chain (think IPFS or a server), these contracts embed everything, metadata and the SVG image itself, right on-chain. When you mint an NFT, the contract creates a unique SVG, encodes it in Base64, and ties it to the token’s metadata, all stored on the blockchain.
This approach makes your NFT completely self-contained. No broken links, no reliance on external hosting, just a standalone piece of art or utility baked into the blockchain’s fabric.
Why Are They Important?
So, why go on-chain? Here’s the deal:
Decentralization: No external servers means your NFT won’t vanish if a host goes offline. It’s all there, on the blockchain.
Permanence: Once it’s on-chain, it’s immutable. Your creation lasts as long as the blockchain does.
Transparency: Anyone can peek at the contract, see how the SVG is generated, and verify the output. No hidden tricks.
These qualities make on-chain SVG NFTs a big deal for creators and collectors who care about longevity and trust.
Use Cases for On-Chain SVG NFTs
What can you do with them? Plenty! Here are some ideas:
Digital Art: Create tamper-proof pieces that stand the test of time.
Generative Art: Write logic to generate unique SVGs based on token IDs or inputs—think algorithm-driven creativity.
Collectibles: Build trading cards or virtual items with guaranteed permanence.
Utility NFTs: Design dynamic visuals for membership tokens or event passes that evolve or display data.
SVGs are lightweight and flexible, so you can play with shapes, colors, or even basic animations. The possibilities? Endless.
Building Your SVG NFT Smart Contract
Let’s roll up our sleeves and build this on Rootstock (RSK), a Bitcoin sidechain that’s EVM-compatible. I’ll break it down into clear steps: setup, coding, and deployment.
Step 1: Setting Up the Environment
First, you’ll need the right tools:
Solidity: The language for writing smart contracts.
Hardhat: Your go-to for compiling, testing, and deploying.
MetaMask: A wallet to connect to the blockchain.
Here’s how to get set up:
Install Node.js from nodejs.org, then add Hardhat:
npm install -g hardhat.Configure MetaMask for the Rootstock Testnet here
Grab some test RBTC from the Rootstock Testnet Faucet.
This gives you a playground to experiment without real costs.
Step 2: Writing the Smart Contract
First, let’s scaffold a new hardhat project by running these commands in your terminal
mkdir onchain-nft
cd onchain-nft
npx hardhat init
And yes(enter) all the way through, i.e., selecting a JavaScript project, and yes to other options

Then open up your project in VS Code by running code . In your terminal
Create a new contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; // Stores tokenURI on-chain (higher gas)import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
contract OnChainNFT is ERC721URIStorage, Ownable(msg.sender) {
event Minted(uint256 tokenId);
uint256 private _tokenIdCounter;
constructor() ERC721("OnChainNFT", "ONC") {}
/* Converts an SVG to Base64 string */
function svgToImageURI(
string memory svg
) public pure returns (string memory) {
string memory baseURL = "data:image/svg+xml;base64,";
string memory svgBase64Encoded = Base64.encode(bytes(svg));
return string(abi.encodePacked(baseURL, svgBase64Encoded));
}
/* Generates a tokenURI using Base64 string as the image */
function formatTokenURI(
string memory imageURI
) public pure returns (string memory) {
return
string(
abi.encodePacked(
"data:application/json;base64,",
Base64.encode(
bytes(
abi.encodePacked(
'{"name": "ON-CHAIN NFT", "description": "A simple SVG based on-chain NFT", "image":"',
imageURI,
'"}'
)
)
)
)
);
}
/* Mints the token */
function mint(string memory svg) public onlyOwner {
string memory imageURI = svgToImageURI(svg);
string memory tokenURI = formatTokenURI(imageURI);
++_tokenIdCounter;
uint256 newItemId = _tokenIdCounter;
_safeMint(msg.sender, newItemId);
_setTokenURI(newItemId, tokenURI);
emit Minted(newItemId);
}
}
⚠️ A Note on Gas Usage
TheERC721URIStorageextension saves full metadata strings (like your Base64-encoded SVGs) on-chain, which can be costly in gas—especially if your SVGs are complex.As an alternative, you could override the
tokenURI()function to generate metadata on the fly usingtokenId, reducing storage usage and saving gas. This approach is ideal when your SVGs are deterministic or generated from contract data.
✅ Example: Overriding tokenURI() to Generate Metadata On-The-Fly
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
contract DynamicOnChainNFT is ERC721 {
uint256 private _tokenIdCounter;
constructor() ERC721("DynamicNFT", "DYN") {}
function mint() public {
_safeMint(msg.sender, _tokenIdCounter);
_tokenIdCounter++;
}
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
// This will revert automatically if token doesn't exist
address owner = ownerOf(tokenId);
require(owner != address(0), "Token does not exist");
string memory svg = generateSVG(tokenId);
string memory image = svgToImageURI(svg);
string memory json = Base64.encode(
bytes(
string(
abi.encodePacked(
'{"name":"Dynamic NFT #',
Strings.toString(tokenId),
'", "description":"A fully on-chain SVG NFT", "image":"',
image,
'"}'
)
)
)
);
return string(abi.encodePacked("data:application/json;base64,", json));
}
function generateSVG(
uint256 tokenId
) internal pure returns (string memory) {
return
string(
abi.encodePacked(
'<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">',
'<rect width="100%" height="100%" fill="hsl(',
Strings.toString((tokenId * 75) % 360),
', 70%, 60%)"/>',
'<text x="10" y="20" font-size="16" fill="#fff">NFT #',
Strings.toString(tokenId),
"</text></svg>"
)
);
}
function svgToImageURI(
string memory svg
) internal pure returns (string memory) {
return
string(
abi.encodePacked(
"data:image/svg+xml;base64,",
Base64.encode(bytes(svg))
)
);
}
}
🔍 Key Highlights
No need for
_setTokenURI(): Saves storage by generating metadata and image entirely in thetokenURI()function.Gas efficiency: Because we avoid writing strings to storage, we significantly reduce deployment/minting costs.
Dynamic visuals: The SVG image is generated using
tokenId, enabling fun generative art features.
Let’s break down the OnChainNFT contract now, piece by piece.
🔹 constructor()
constructor() ERC721("OnChainNFT", "ONC") {}
Initializes the NFT collection with the name "OnChainNFT" and symbol "ONC".
Inherits from OpenZeppelin's
ERC721.
🔹 svgToImageURI(string memory svg)
function svgToImageURI(
string memory svg
) public pure returns (string memory) {
string memory baseURL = "data:image/svg+xml;base64,";
string memory svgBase64Encoded = Base64.encode(bytes(svg));
return string(abi.encodePacked(baseURL, svgBase64Encoded));
}
Converts an SVG string into a Base64-encoded image URI.
Output:
data:image/svg+xml;base64,<base64-encoded SVG>
📦 Example:
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0..."
🔹 formatTokenURI(string memory imageURI)
function formatTokenURI(
string memory imageURI
) public pure returns (string memory) {
return
string(
abi.encodePacked(
"data:application/json;base64,",
Base64.encode(
bytes(
abi.encodePacked(
'{"name": "ON-CHAIN NFT", "description": "A simple SVG based on-chain NFT", "image":"',
imageURI,
'"}'
)
)
)
)
);
}
It takes a Base64-encoded image URI and wraps it in a full Base64-encoded JSON metadata object.
The output is a full ERC721-compatible
tokenURIstring.
📦 Output format:
"data:application/json;base64,<base64-encoded JSON>"
With JSON content like:
{
"name": "ON-CHAIN NFT",
"description": "A simple SVG based on-chain NFT",
"image": "<imageURI>"
}
🔹 mint(string memory svg)
function mint(string memory svg) public onlyOwner {
string memory imageURI = svgToImageURI(svg);
string memory tokenURI = formatTokenURI(imageURI);
++_tokenIdCounter;
uint256 newItemId = _tokenIdCounter;
_safeMint(msg.sender, newItemId);
_setTokenURI(newItemId, tokenURI);
emit Minted(newItemId);
}
Can only be called by the contract owner (inherited from
Ownable).Steps:
Takes the SVG string you pass in.
Converts it to an image URI (
svgToImageURI).Generates the metadata (
formatTokenURI).Increments the token counter.
Mints the NFT and assigns it to the owner.
Sets the tokenURI (i.e., metadata) on-chain.
Emits a
Minted(tokenId)event.
🔸 event Minted(uint256 tokenId);
- It emits an event whenever a new token is minted, helpful for tracking off-chain.
🔸 uint256 private _tokenIdCounter;
- Internal counter for assigning unique token IDs.
🛡️ Security Tip:
Never accept untrusted SVGs without validation! SVGs can contain malicious scripts if you’re not careful. Always sanitize input or restrict allowed elements to prevent unexpected behavior.A simple check like ensuring no
<script>tags are present can go a long way:require(!bytes(svg).contains("<script>"), "Unsafe SVG detected");(You can also sanitize on the frontend before calling
mint().)If you're using plain Solidity, you may want to offload deep sanitization to your dApp frontend.
Now, at this point, we need to install @openzeppelin/contracts, run the command below to install
npm i @openzeppelin/contracts
After installing OpenZeppelin Contracts, you can compile your contracts, ensure no errors, and run npx hardhat compile to compile your contracts

Step 4: Deploying to Rootstock Testnet
Now we’ll be writing a deployment script to deploy this contract. Create a new file scripts/deploy.js In the root directory of your hardhat project
// scripts/deploy.js
const main = async () => {
// Get 'OnChainNFT' contract
const nftContractFactory = await hre.ethers.getContractFactory("OnChainNFT");
// Deploy contract
const nftContract = await nftContractFactory.deploy();
await nftContract.waitForDeployment();
console.log("✅ Contract deployed to:", nftContract.target);
};
const runMain = async () => {
try {
await main();
process.exit(0);
} catch (error) {
console.log(error);
process.exit(1);
}
};
runMain();
And so we need to set up your hardhat.config.js file for deployment
require('dotenv').config();
require("@nomicfoundation/hardhat-toolbox");
/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
solidity: "0.8.28",
networks: {
// for testnet
rootstock: {
url: process.env.ROOTSTOCK_TESTNET_RPC_URL,
accounts: [process.env.WALLET_KEY],
},
},
etherscan: {
// Use "123" as a placeholder, because Blockscout doesn't need a real API key, and Hardhat will complain if this property isn't set.
apiKey: {
rootstock: '123',
},
customChains: [
{
network: "rootstock",
chainId: 31,
urls: {
apiURL: "https://rootstock-testnet.blockscout.com/api/",
browserURL: "https://rootstock-testnet.blockscout.com/",
}
},
],
},
sourcify: {
enabled: false,
}
};
You now have to install dotenv. You do that by running the command
npm i --save-dev dotenv
and then create an .env file in the root directory of your hardhat project
WALLET_KEY=your-wallet-private-key
ROOTSTOCK_TESTNET_RPC_URL=your-alchemy-rpc-url
You can get your rootstock testnet RPC URL by heading to your Alchemy dashboard

But before we run this deployment script to deploy the contract, let’s add something, let’s add to this script above a functionality to mint an OnChain SVG NFT immediately after deployment
Step 5: Minting your OnChain NFT
Here’s an image of the SVG NFT we’re going to be minting

// scripts/deploy.js
const main = async () => {
// Get 'OnChainNFT' contract
const nftContractFactory = await hre.ethers.getContractFactory("OnChainNFT");
// Deploy contract
const nftContract = await nftContractFactory.deploy();
await nftContract.deployed;
console.log("✅ Contract deployed to:", nftContract.target);
// here's an svg image of rootstock
const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 27.6.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
version="1.1"
id="Layer_1"
x="0px"
y="0px"
viewBox="0 0 390 390"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs2" /> <style
type="text/css"
id="style1"> .st0{fill:#4B5CF0;} .st1{fill:#FFFFFF;} </style> <circle
class="st0"
cx="195"
cy="195"
r="195"
id="circle1" /> <path
class="st1"
d="m 290.3,99.1 v 191.2 h -41.2 v -99.9 h -33.9 c -4,0 -7.8,1.5 -10.7,4.1 -1.7,1.5 -2.9,3.3 -3.8,5.4 -0.9,2.1 -1.4,4.2 -1.4,6.5 v 23 c 0,6.1 -5,11.3 -11.3,11.3 h -27.2 c -6.1,0 -11.3,-5 -11.3,-11.3 v -27.8 c 0,-6.1 5,-11.3 11.3,-11.3 h 24.6 c 3.3,0 6.5,-1.1 8.9,-3.3 1.5,-1.3 2.8,-2.9 3.7,-4.9 0.9,-1.8 1.3,-3.8 1.3,-5.9 V 140.1 H 99.9 V 99 Z"
id="path1" /> <path
class="st1"
d="m 149.5,265.6 v 0.4 c 0,13.8 -11.1,24.9 -24.9,24.9 -13.8,0 -24.8,-11.1 -24.8,-24.9 v -0.4 c 0,-13.8 11.1,-24.9 24.9,-24.9 6.9,0 13.1,2.8 17.6,7.3 4.5,4.6 7.1,10.7 7.2,17.6 z"
id="path2" /> </svg>`;
// Here we call the mint function from our contract
const txn = await nftContract.mint(svg);
const txnReceipt = await txn.wait();
const event = txnReceipt?.logs
.filter((log) => log.fragment?.name === "Minted")
.map((log) => log)[0];
const tokenId = event?.args?.tokenId;
console.log(
"🎨 Your minted NFT:",
`https://rootstock-testnet.blockscout.com/token/${nftContract.target}/instance/${tokenId}`
);
};
const runMain = async () => {
try {
await main();
process.exit(0);
} catch (error) {
console.log(error);
process.exit(1);
}
};
runMain();
Finally, we can run the deploy script command.
npx hardhat run scripts/deploy.js --network rootstock

And here’s your OnChainNFT, following the link in the console

Conclusion
On-chain SVG NFTs are a game-changer, decentralized, permanent, and packed with potential. By embedding the SVG in the contract, you create assets that stand alone, free from external risks. Whether you’re an artist crafting timeless pieces or a developer pushing generative boundaries, this tech lets you shine.
Why stop here? Experiment with patterns, colors, or interactive twists. Dig into the Rootstock docs for more tutorials and tools and to level up your skills, and join the Rootstock Discord Channel to stay connected with other builders. Start minting your pixels on-chain—your blockchain canvas is waiting!
The source code for this article can be found at https://github.com/michojekunle/nft-onchain/tree/rsk-onchain-nft. Happy Building!





