Building an NFT platform taught me that blockchain development is fundamentally different from traditional web development. Immutability, gas costs, and decentralization introduce constraints that require completely different architectural thinking.
Here's everything I learned building a production NFT platform with Ethereum, including smart contract design, wallet integration, IPFS storage, and gas optimization.
NFT Platform Architecture
A production NFT platform has several components:
- Smart Contracts: ERC-721/ERC-1155 token contracts on Ethereum
- IPFS: Decentralized storage for metadata and images
- Backend API: Indexing, caching, user management (Node.js/NestJS)
- Frontend: Web3 wallet integration (React/Next.js)
- Database: Off-chain data for fast queries (PostgreSQL)
The key insight: you can't put everything on-chain. Use blockchain for ownership and transfers; use traditional tech for everything else.
Smart Contract Development
We used ERC-721 for unique NFTs with OpenZeppelin's battle-tested contracts:
// contracts/MyNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
contract MyNFT is ERC721, ERC721URIStorage, Ownable {
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
// Royalty info
address public royaltyRecipient;
uint256 public royaltyPercentage; // Basis points (e.g., 500 = 5%)
// Max supply
uint256 public constant MAX_SUPPLY = 10000;
// Mint price
uint256 public mintPrice = 0.05 ether;
// Events
event NFTMinted(address indexed to, uint256 indexed tokenId, string tokenURI);
event RoyaltyUpdated(address indexed recipient, uint256 percentage);
constructor(
string memory name,
string memory symbol,
address _royaltyRecipient,
uint256 _royaltyPercentage
) ERC721(name, symbol) {
royaltyRecipient = _royaltyRecipient;
royaltyPercentage = _royaltyPercentage;
}
function mint(address to, string memory tokenURI) public payable returns (uint256) {
require(_tokenIds.current() < MAX_SUPPLY, "Max supply reached");
require(msg.value >= mintPrice, "Insufficient payment");
_tokenIds.increment();
uint256 newTokenId = _tokenIds.current();
_safeMint(to, newTokenId);
_setTokenURI(newTokenId, tokenURI);
emit NFTMinted(to, newTokenId, tokenURI);
return newTokenId;
}
function batchMint(
address[] memory recipients,
string[] memory tokenURIs
) public payable onlyOwner {
require(recipients.length == tokenURIs.length, "Array length mismatch");
require(
_tokenIds.current() + recipients.length <= MAX_SUPPLY,
"Exceeds max supply"
);
for (uint256 i = 0; i < recipients.length; i++) {
_tokenIds.increment();
uint256 newTokenId = _tokenIds.current();
_safeMint(recipients[i], newTokenId);
_setTokenURI(newTokenId, tokenURIs[i]);
emit NFTMinted(recipients[i], newTokenId, tokenURIs[i]);
}
}
function setMintPrice(uint256 newPrice) public onlyOwner {
mintPrice = newPrice;
}
function setRoyalty(address recipient, uint256 percentage) public onlyOwner {
require(percentage <= 1000, "Royalty too high"); // Max 10%
royaltyRecipient = recipient;
royaltyPercentage = percentage;
emit RoyaltyUpdated(recipient, percentage);
}
// ERC-2981 royalty standard
function royaltyInfo(
uint256 tokenId,
uint256 salePrice
) public view returns (address, uint256) {
uint256 royaltyAmount = (salePrice * royaltyPercentage) / 10000;
return (royaltyRecipient, royaltyAmount);
}
function withdraw() public onlyOwner {
uint256 balance = address(this).balance;
payable(owner()).transfer(balance);
}
function totalSupply() public view returns (uint256) {
return _tokenIds.current();
}
// Required overrides
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721URIStorage)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {
super._burn(tokenId);
}
}IPFS Integration for Metadata
NFT metadata must be stored immutably. We used IPFS (InterPlanetary File System):
// infrastructure/ipfs/ipfs.service.ts
import { create, IPFSHTTPClient } from 'ipfs-http-client';
import { File } from 'buffer';
@Injectable()
export class IPFSService {
private client: IPFSHTTPClient;
constructor() {
// Use Infura's IPFS gateway or run your own node
this.client = create({
host: 'ipfs.infura.io',
port: 5001,
protocol: 'https',
headers: {
authorization: `Basic ${Buffer.from(
process.env.INFURA_PROJECT_ID + ':' + process.env.INFURA_SECRET
).toString('base64')}`,
},
});
}
async uploadImage(buffer: Buffer, filename: string): Promise<string> {
const file = {
path: filename,
content: buffer,
};
const result = await this.client.add(file);
// Return IPFS URL
return `ipfs://${result.cid}`;
}
async uploadMetadata(metadata: NFTMetadata): Promise<string> {
const json = JSON.stringify(metadata);
const result = await this.client.add(json);
return `ipfs://${result.cid}`;
}
async getMetadata(ipfsUrl: string): Promise<NFTMetadata> {
// Convert ipfs:// to https://
const cid = ipfsUrl.replace('ipfs://', '');
const url = `https://ipfs.io/ipfs/${cid}`;
const response = await fetch(url);
return await response.json();
}
}
// NFT Metadata standard (ERC-721)
interface NFTMetadata {
name: string;
description: string;
image: string; // IPFS URL
attributes?: Array<{
trait_type: string;
value: string | number;
}>;
external_url?: string;
}Minting flow with IPFS:
// application/use-cases/mint-nft.use-case.ts
@Injectable()
export class MintNFTUseCase {
constructor(
private readonly ipfs: IPFSService,
private readonly web3: Web3Service,
private readonly nftRepository: NFTRepository
) {}
async execute(
userId: string,
imageFile: Buffer,
metadata: Partial<NFTMetadata>
): Promise<{ tokenId: number; transactionHash: string }> {
// 1. Upload image to IPFS
const imageUrl = await this.ipfs.uploadImage(
imageFile,
`nft-${Date.now()}.png`
);
// 2. Create metadata with IPFS image URL
const fullMetadata: NFTMetadata = {
name: metadata.name!,
description: metadata.description!,
image: imageUrl,
attributes: metadata.attributes || [],
};
// 3. Upload metadata to IPFS
const metadataUrl = await this.ipfs.uploadMetadata(fullMetadata);
// 4. Mint NFT on blockchain
const user = await this.userRepository.findById(userId);
const result = await this.web3.mintNFT(user.walletAddress, metadataUrl);
// 5. Store in database for fast queries
await this.nftRepository.create({
tokenId: result.tokenId,
owner: user.walletAddress,
metadataUrl,
imageUrl,
name: metadata.name!,
transactionHash: result.transactionHash,
});
return result;
}
}Web3 Service for Blockchain Interaction
Interacting with smart contracts from Node.js:
// infrastructure/web3/web3.service.ts
import { ethers } from 'ethers';
import contractABI from './MyNFT.json';
@Injectable()
export class Web3Service {
private provider: ethers.providers.Provider;
private contract: ethers.Contract;
private wallet: ethers.Wallet;
constructor() {
// Connect to Ethereum (Infura, Alchemy, or local node)
this.provider = new ethers.providers.JsonRpcProvider(
process.env.ETH_RPC_URL
);
// Wallet for signing transactions
this.wallet = new ethers.Wallet(
process.env.PRIVATE_KEY!,
this.provider
);
// Contract instance
this.contract = new ethers.Contract(
process.env.NFT_CONTRACT_ADDRESS!,
contractABI.abi,
this.wallet
);
}
async mintNFT(
toAddress: string,
tokenURI: string
): Promise<{ tokenId: number; transactionHash: string }> {
// Get mint price from contract
const mintPrice = await this.contract.mintPrice();
// Send transaction
const tx = await this.contract.mint(toAddress, tokenURI, {
value: mintPrice,
gasLimit: 300000, // Set appropriate gas limit
});
// Wait for confirmation
const receipt = await tx.wait();
// Parse event to get token ID
const event = receipt.events?.find((e: any) => e.event === 'NFTMinted');
const tokenId = event?.args?.tokenId.toNumber();
return {
tokenId,
transactionHash: receipt.transactionHash,
};
}
async getTokenURI(tokenId: number): Promise<string> {
return await this.contract.tokenURI(tokenId);
}
async getOwner(tokenId: number): Promise<string> {
return await this.contract.ownerOf(tokenId);
}
async getTotalSupply(): Promise<number> {
const supply = await this.contract.totalSupply();
return supply.toNumber();
}
async estimateGas(toAddress: string, tokenURI: string): Promise<string> {
const mintPrice = await this.contract.mintPrice();
const gasEstimate = await this.contract.estimateGas.mint(
toAddress,
tokenURI,
{ value: mintPrice }
);
const gasPrice = await this.provider.getGasPrice();
const gasCost = gasEstimate.mul(gasPrice);
return ethers.utils.formatEther(gasCost);
}
}Frontend Wallet Integration
Connect to user wallets with MetaMask:
// lib/web3.ts
import { ethers } from 'ethers';
export async function connectWallet(): Promise<{
address: string;
provider: ethers.providers.Web3Provider;
}> {
if (!window.ethereum) {
throw new Error('MetaMask not installed');
}
const provider = new ethers.providers.Web3Provider(window.ethereum);
// Request account access
await provider.send('eth_requestAccounts', []);
const signer = provider.getSigner();
const address = await signer.getAddress();
return { address, provider };
}
export async function mintNFT(
contractAddress: string,
tokenURI: string,
mintPrice: string
): Promise<{ transactionHash: string }> {
const { provider } = await connectWallet();
const signer = provider.getSigner();
const contract = new ethers.Contract(
contractAddress,
contractABI,
signer
);
const tx = await contract.mint(await signer.getAddress(), tokenURI, {
value: ethers.utils.parseEther(mintPrice),
});
await tx.wait();
return { transactionHash: tx.hash };
}React component for minting:
const MintNFT = () => {
const [connected, setConnected] = useState(false);
const [address, setAddress] = useState('');
const [minting, setMinting] = useState(false);
const handleConnect = async () => {
try {
const { address } = await connectWallet();
setAddress(address);
setConnected(true);
} catch (error) {
toast.error('Failed to connect wallet');
}
};
const handleMint = async (imageFile: File, metadata: NFTMetadata) => {
setMinting(true);
try {
// 1. Upload to backend (which uploads to IPFS)
const formData = new FormData();
formData.append('image', imageFile);
formData.append('metadata', JSON.stringify(metadata));
const response = await fetch('/api/nft/prepare-mint', {
method: 'POST',
body: formData,
});
const { tokenURI, mintPrice } = await response.json();
// 2. Mint via wallet
const { transactionHash } = await mintNFT(
process.env.NEXT_PUBLIC_CONTRACT_ADDRESS!,
tokenURI,
mintPrice
);
// 3. Confirm on backend
await fetch('/api/nft/confirm-mint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactionHash }),
});
toast.success('NFT minted successfully!');
} catch (error) {
toast.error('Minting failed');
} finally {
setMinting(false);
}
};
return (
<div>
{!connected ? (
<button onClick={handleConnect}>Connect Wallet</button>
) : (
<div>
<p>Connected: {address.slice(0, 6)}...{address.slice(-4)}</p>
<NFTMintForm onMint={handleMint} minting={minting} />
</div>
)}
</div>
);
};Gas Optimization Strategies
Gas costs can make or break an NFT platform. Here's how we optimized:
1. Batch Operations
Minting 100 NFTs individually vs. one batch:
// Bad: 100 separate transactions = ~$500 in gas
for (uint i = 0; i < 100; i++) {
mint(recipients[i], tokenURIs[i]);
}
// Good: 1 transaction = ~$50 in gas
function batchMint(
address[] memory recipients,
string[] memory tokenURIs
) public {
for (uint256 i = 0; i < recipients.length; i++) {
_mint(recipients[i], _tokenIds.current());
_setTokenURI(_tokenIds.current(), tokenURIs[i]);
_tokenIds.increment();
}
}2. Storage Optimization
Use events for data that doesn't need on-chain queries:
// Bad: Expensive storage
mapping(uint256 => string) public tokenDescriptions;
// Good: Emit event, index off-chain
event TokenMinted(uint256 indexed tokenId, string description);3. Off-Chain Metadata
Store metadata on IPFS, not on-chain:
// Bad: Storing full metadata on-chain
struct NFTData {
string name;
string description;
string imageUrl;
string[] attributes;
}
// Good: Store only IPFS hash
string private _baseTokenURI = "ipfs://Qm...";4. Use uint256 Efficiently
Pack variables to save storage slots:
// Bad: Each takes a storage slot
uint256 public royaltyPercentage; // 1 slot
address public royaltyRecipient; // 1 slot
// Good: Packed into 1 slot
struct RoyaltyInfo {
address recipient; // 20 bytes
uint96 percentage; // 12 bytes
} // Total: 32 bytes = 1 slotIndexing with The Graph
Query blockchain data efficiently with The Graph:
# schema.graphql
type NFT @entity {
id: ID!
tokenId: BigInt!
owner: Bytes!
metadataURI: String!
mintedAt: BigInt!
transactionHash: Bytes!
}
type Transfer @entity {
id: ID!
tokenId: BigInt!
from: Bytes!
to: Bytes!
timestamp: BigInt!
transactionHash: Bytes!
}Query from frontend:
const GET_USER_NFTS = gql`
query GetUserNFTs($owner: Bytes!) {
nfts(where: { owner: $owner }) {
id
tokenId
metadataURI
mintedAt
}
}
`;
const { data } = useQuery(GET_USER_NFTS, {
variables: { owner: userAddress.toLowerCase() },
});Lessons Learned
- Gas costs are real: Every storage write costs money. Optimize aggressively.
- Use IPFS for storage: Never store large data on-chain.
- Testnet extensively: Mainnet mistakes are expensive and permanent.
- Security audits are essential: Use OpenZeppelin, get audited before mainnet.
- Index everything off-chain: Blockchain queries are slow; index to PostgreSQL/The Graph.
Conclusion
Building NFT platforms requires mastering both Web3 and traditional web development. Use blockchain for ownership and transfers, IPFS for immutable storage, and traditional databases for fast queries.
The key is understanding what goes on-chain (minimal, expensive) vs. off-chain (rich data, fast queries). After 27 years in software, blockchain taught me that constraints breed creativity—gas optimization and immutability force better architectural decisions.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.