Implementing a chain NFT in Solidity with Openzeppelin

Introduction
The Non-Fungible Token (NFT) revolution has transformed digital ownership, enabling artists, developers and creators to tokenize digital assets directly on the blockchain. While many NFTs rely on off-chain metadata (stored on IPFS or centralized servers), On-Chain NFTs store everything - including metadata and images - directly in the smart contract.
In this article, we'll explore how to create a complete On-Chain NFT using Solidity and review its key components. By the end, you'll understand how to generate SVG images and metadata entirely on-chain, ensuring that your NFTs are immutable and decentralized forever.
What is an NFT?
An NFT is a digital asset that represents the ownership of a unique object. Unlike fungible tokens (such as the ERC-20), NFTs are non-interchangeable - each token is unique. They follow the ERC-721 standard, which defines the rules for creating, transferring and retrieving metadata.
NFT On-Chain vs Off-Chain
Type | Description |
Off-Chain | Metadata (image, description, etc.) is stored on IPFS or external servers. The contract only stores the link (URI). |
On-Chain | All metadata and the image (usually SVG) are stored directly in the contract itself. |
NFT On-Chain are more resilient because they do not depend on external file storage.
NFT On-Chain contract structure
Our On-Chain NFT will follow the ERC-721 standard and extend OpenZeppelin's ERC721URIStorage
.
Here's what we'll be implementing:
Key methods
mint
Creates a new NFT with an auto-generated SVG image and metadata stored directly on the string.Generates metadata (name, description, image) encoded in Base64.
Generate SVG image (entirely on string).
Contract implementation
Contract declaration
// SPDX-License-Identifier : MIT
pragma solidity ^0.8.28 ;
import "@openzeppelin/contrats/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
contract NFTContract is ERC721URIStorage {
uses Strings for uint256 ;
uint256 public nextTokenId ;
uint256 public totalSupply;
address public owner ;
mapping(address => bool) private minters;
We import useful utilities:
Base64
,Strings
andERC721URIStorage
.nextTokenId
tracks the number of NFTs typed.owner
andminters
manage access control.
Costum Errors
error InvalidAddress();
error onlyMinterCanMint();
error onlyOwnerCanSetMinter();
What do they do?
They define named errors that your contract can trigger (roll back) when certain conditions fail. Instead of using the old require() method with a string message (which costs more gas), you can simply back out with these custom errors.
For example:
if (minters[msg.sender] == false ) {
revert onlyMinterCanMint();
}
This is cheaper (gas-wise) than:
require(minter[msg.sender] == true, "Only minter can mint");
To understant more about costum errors in solidity can read theses article:
Constructor and modifiers
constructor(
string memory _name,
string memory _symbol
) ERC721(_name, _symbol) {
owner = msg.sender;
minters[msg.sender] = true;
}
modifier onlyOwner() {
if (msg.sender != owner) {
revert onlyOwnerCanSetMinter();
}
_;
}
modifier onlyMinter() {
if (minters[msg.sender] == false) {
revert onlyMinterCanMint();
}
_;
}
- `onlyOwner` and `onlyMinter` restrict access to sensitive functions.
- `setMinter` allows the owner to delegate mining rights.
function mint(address _to) public onlyMinter {
if (_to == address(0)) {
revert InvalidAddress();
}
nextTokenId++;
uint256 newNFTId = nextTokenId;
_mint(_to, newNFTId);
_setTokenURI(newNFTId, getTokenURI(newNFTId));
totalSupply += 1;
}
Mints a new NFT to
_to
.Generates and stores its metadata directly via
getTokenURI
.increase the
totalSupply
Chain metadata generation
function getTokenURI(uint256 tokenId) public pure returns (string memory) {
bytes memory dataURI = abi.encodePacked(
"{",
'"name": "Wass on chain NFT #',
tokenId.toString(),
'",',
'"description": "This image features a modern, minimalist design with a square orange background and rounded corners. On this vibrant background stands out a white geometric shape resembling a triangle with a rounded side, positioned in the corner. A perfect black circle sits at the top of the image, creating a striking contrast with the other elements. A subtle touch is added by a small semi-transparent rectangle in pale orange in the bottom right-hand corner, slightly rotated, which adds depth and dimension to the composition. The whole creates a harmonious balance between simple forms and vivid colors, reflecting a contemporary, uncluttered style. ",',
'"image": "',
generateCharacter(),
'"',
"}"
);
return
string(
abi.encodePacked(
"data:application/json;base64,",
Base64.encode(dataURI)
)
);
}
This generates a JSON metadata object directly in Solidity.
It encodes the JSON in Base64 and return it.
SVG generation on string
function generateCharacter() public pure returns (string memory) {
bytes memory svg = abi.encodePacked(
'<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 124 124" fill="none">',
'<rect width="124" height="124" rx="24" fill="#F97316"/>',
'<path d="M19.375 36.7818V100.625C19.375 102.834 21.1659 104.625 23.375 104.625H87.2181C90.7818 104.625 92.5664 100.316 90.0466 97.7966L26.2034 33.9534C23.6836 31.4336 19.375 33.2182 19.375 36.7818Z" fill="white"/>',
'<circle cx="63.2109" cy="37.5391" r="18.1641" fill="black"/>',
'<rect opacity="0.4" x="81.1328" y="80.7198" width="17.5687" height="17.3876" rx="4" transform="rotate(-45 81.1328 80.7198)" fill="#FDBA74"/>',
"</svg>"
);
return
string(
abi.encodePacked(
"data:image/svg+xml;base64,",
Base64.encode(svg)
)
);
}
Defines a simple SVG image.
Includes a green background and some text.
Converts the SVG into a Base64 data URI.
Reading metadata (tokenURI)
function tokenURI(
uint256 tokenId
) public view virtual override returns (string memory) {
string memory baseURI = getTokenURI(tokenId);
return
bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI)) : "";
}
This overrides the ERC-721 tokenURI function.
It directly returns the OnChain metadata (no IPFS or external URL).
bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI)) : "";
: If baseURI is not empty, return it as a string, otherwise return an empty string.
Conclusion
Thanks for reading. You can find the full code here: https://github.com/WassCodeur/assigmntNFTERC721.git.
The conctrat on sepolia Etherscan: https://sepolia.etherscan.io/address/0x61A66a623F0A7c84ae33b602fd9aAB570fD57Efc#code
NFT on opensea: https://testnets.opensea.io/assets/sepolia/0x61a66a623f0a7c84ae33b602fd9aab570fd57efc/1
Subscribe to my newsletter
Read articles from Wasiu Ibrahim directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Wasiu Ibrahim
Wasiu Ibrahim
I'm a passionate software developer(backend), technical writer and community facilitator, committed to strengthening the links between developers and emerging technologies. My background has also led me to actively contribute to open source projects, where I find great satisfaction in collaborating with other technology enthusiasts to create solutions accessible to all. Join me on this adventure where we can not only develop innovative software, but also nurture a dynamic community and participate in the evolution of open source for a more inclusive and collaborative digital future