DEV Community

Cover image for Building Fully On-Chain NFTs with Solidity: A Step-by-Step Guide
ORJI CECILIA O.
ORJI CECILIA O.

Posted on

2

Building Fully On-Chain NFTs with Solidity: A Step-by-Step Guide

Introduction
In the world of NFTs, most digital assets rely on off-chain storage solutions like IPFS or centralized servers for storing metadata and images. But what if you could store everything directly on the blockchain—including the NFT artwork?

In this article, we’ll walk through the creation of an on-chain NFT using Solidity. This NFT contract generates SVG images dynamically, encodes them in Base64, and serves them directly from the smart contract—ensuring permanence and full decentralization.

By the end, you'll understand how to:

  • Generate on-chain SVG images for NFTs
  • Use Base64 encoding to store images within smart contracts
  • Implement a fully decentralized ERC-721 contract

Let's get into it!

Understanding On-Chain NFTs
Traditional NFTs store images and metadata off-chain, meaning if a hosting service goes down, the NFT might lose its artwork. On-chain NFTs, on the other hand, embed all data within the blockchain, ensuring that they remain accessible as long as Ethereum exists.

In this tutorial, we’ll create a fully on-chain NFT that:

  • Uses Solidity to generate an SVG image
  • Stores both the image and metadata directly on the blockchain
  • Mints NFTs dynamically without relying on external storage

This means that every NFT image is programmatically generated and stored directly inside the smart contract.

The Smart Contract
Here’s the full Solidity code for our on-chain NFT contract.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract OnChainNFT is ERC721 {
    using Strings for uint;
    uint256 public tokenId;

    constructor() ERC721 ("ceceNFT", "cece") {
        tokenId = 1;
    }

    event NFTMinted(address indexed owner, uint256 tokenId);

    function generateSVG() internal pure returns (string memory) {
        string memory svg = string(
            abi.encodePacked(
            '<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500">',
            '<rect width="100%" height="100%" fill="black" />',
            '<circle cx="250" cy="250" r="210" fill="orange" />',
            '<circle cx="250" cy="250" r="190" fill="white" />',
            '<circle cx="250" cy="250" r="170" fill="red" />',
            '<circle cx="250" cy="250" r="150" fill="yellow" />',
            '<circle cx="250" cy="250" r="130" fill="green" />',
            '<circle cx="250" cy="250" r="110" fill="blue" />',
            '<circle cx="250" cy="250" r="90" fill="purple" />',
            '<circle cx="250" cy="250" r="70" fill="pink" />',
            '<circle cx="250" cy="250" r="50" fill="brown" />',
            '<circle cx="250" cy="250" r="30" fill="black" />',
            '</svg>'
        ));
        return string(abi.encodePacked("data:image/svg+xml;base64,", Base64.encode(bytes(svg))));
    }

    function mintNFT() public {
        tokenId++;
        _safeMint(msg.sender, tokenId);
        emit NFTMinted(msg.sender, tokenId);
    }

    function tokenURI(uint256 tokenTxId) public view override returns (string memory) {
        ownerOf(tokenTxId);

        string memory name = string(abi.encodePacked("cece ", Strings.toString(tokenTxId)));
        string memory description = "My NFT";
        string memory image = generateSVG();

        string memory json = string(
            abi.encodePacked(
                '{"name": "', name, '", ',
                '"description": "', description, '", ',
                '"image": "', image, '"}'
            )
        );
        return string(abi.encodePacked("data:application/json;base64,", Base64.encode(bytes(json))));
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s break this contract down step by step.
1. Contract Setup

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
Enter fullscreen mode Exit fullscreen mode
  • ERC721.sol → Standard implementation for NFTs.
  • Base64.sol → Used to encode images & metadata in Base64 format.
  • Strings.sol → Helps convert numbers to string format.

If you haven't installed OpenZeppelin yet, run in your terminal

npm install @openzeppelin/contracts

2. Generating the SVG Image

function generateSVG() internal pure returns (string memory) {

  • This function creates the SVG artwork for the NFT.
  • Each circle inside the SVG creates a layered bullseye effect.
  • The final SVG is Base64 encoded so it can be stored directly in the smart contract.

Example Output of generateSVG()
data:image/svg+xml;base64,PHN2ZyB...

This can be directly displayed in browsers or NFT marketplaces.

3. Minting the NFT

function mintNFT() public {
tokenId++;
_safeMint(msg.sender, tokenId);
emit NFTMinted(msg.sender, tokenId);
}

  • Calls _safeMint() to safely assign a new NFT to msg.sender.
  • Uses an incrementing tokenId to ensure each NFT has a unique identifier.
  • Emits an event to log the minting.

4. Generating Metadata for Marketplaces

function tokenURI(uint256 tokenTxId) public view override returns (string memory) {

  • Generates the NFT metadata in JSON format.
  • Encodes everything in Base64 to be fully on-chain.

Example Output (for tokenTxId = 1)

{
"name": "cece 1",
"description": "My NFT",
"image": "data:image/svg+xml;base64,PHN2ZyB..."
}

NB - This metadata is formatted to match OpenSea & other marketplaces.

Why On-Chain NFTs Are Revolutionary
Unlike traditional NFTs that rely on external servers or IPFS, on-chain NFTs ensure:

  • Permanence → Your NFT never disappears (even if IPFS goes offline).
  • Decentralization → No third-party hosting needed.
  • Immutability → The image & metadata live forever on the blockchain.

Deploying Your On-Chain NFT to Base Sepolia

Now that we've built our fully on-chain NFT contract, let's deploy it to Base Sepolia, a testnet optimized for Ethereum L2 solutions.

Setting Up Your Deployment Script (deploy.ts)

Create a new file inside the scripts/ folder:
scripts/deploy.ts

`import { ethers } from "hardhat";

async function main() {
console.log("Deploying NFT contract...");

const NFT = await ethers.getContractFactory("OnChainNFT");
const nft = await NFT.deploy();

await nft.waitForDeployment();

const contractAddress =  await nft.getAddress();

console.log("Minting first NFT...");

const tx = await nft.mintNFT();
await tx.wait();

console.log("NFT minted at address:", contractAddress);
Enter fullscreen mode Exit fullscreen mode

}

main().catch((error) => {
console.error("deployment failed:",error);
process.exitCode = 1;
});
`
This script does the following:

  • Compiles the NFT contract
  • Deploys it to the Base Sepolia network
  • Mints the first NFT automatically after deployment

Configuring Hardhat for Base Sepolia

Now, update your Hardhat configuration to support Base Sepolia.
hardhat.config.ts

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import "@nomicfoundation/hardhat-verify";
import "dotenv/config"; // Use dotenv for environment variables

const { 
  ALCHEMY_BASE_SEPOLIA_API_KEY_URL,
  ACCOUNT_PRIVATE_KEY, 
  ETHERSCAN_API_KEY 
} = process.env;

const config: HardhatUserConfig = {
  solidity: "0.8.28",

  networks: {
    base_sepolia: {
      url: ALCHEMY_BASE_SEPOLIA_API_KEY_URL,
      accounts: ACCOUNT_PRIVATE_KEY ? [`0x${ACCOUNT_PRIVATE_KEY}`] : [],
      timeout: 120000, 
    },
  },

  etherscan: {
    apiKey: ETHERSCAN_API_KEY,
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

What This Does:

  • Configures Base Sepolia testnet using Alchemy
  • Loads private keys securely from .env
  • Enables contract verification on Etherscan

Setting Up Environment Variables (.env File)

Create a .env file in the root of your Hardhat project and add:

ALCHEMY_BASE_SEPOLIA_API_KEY_URL="https://basesepolia.alchemyapi.io/v2/YOUR_ALCHEMY_KEY"
ACCOUNT_PRIVATE_KEY="YOUR_WALLET_PRIVATE_KEY"
ETHERSCAN_API_KEY="YOUR_ETHERSCAN_API_KEY"
Enter fullscreen mode Exit fullscreen mode

NB - Never hardcode private keys in your code. Always use environment variables.

Deploying the NFT to Base Sepolia

A - Install Dependencies
If you haven’t already installed Hardhat and the required plugins:

npm install @nomicfoundation/hardhat-toolbox @nomicfoundation/hardhat-verify dotenv ethers hardhat

B - Compile the Contract
Run:
npx hardhat compile

C - Deploy the Contract
Run:
npx hardhat run scripts/deploy.ts --network base_sepolia

Deploys the OnChainNFT contract to Base Sepolia
Automatically mints the first NFT after deployment

D - Expected output after deployment

npx hardhat run scripts/deploy.ts --network base_sepolia
Compiled 1 Solidity file successfully (evm target: paris).
Deploying NFT contract...
Minting first NFT...
NFT minted at address: 0x81783AC13C6Bdf030d8De279a13b63E4406287Da

Verifying the Contract on Etherscan
This is optional and it is to make your contract publicly viewable, verify it on Base Sepolia Etherscan:

npx hardhat verify --network base_sepolia 0xYourDeployedContractAddress

This allows anyone to interact with your NFT contract directly from Etherscan.

Viewing Your On-Chain NFT on OpenSea
To view on opensea, go to url, signup and connect your wallet if you are not signed up. Go to your account or profile and you can view your nft. Here is our minted nft

Image description

Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

While many AI coding tools operate as simple command-response systems, Qodo Gen 1.0 represents the next generation: autonomous, multi-step problem-solving agents that work alongside you.

Read full post

Top comments (0)

Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

Rather than just generating snippets, our agents understand your entire project context, can make decisions, use tools, and carry out tasks autonomously.

Read full post