← Back to Blog
NFT Platform Development: Ethereum Integration Best Practices

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 slot

Indexing 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

  1. Gas costs are real: Every storage write costs money. Optimize aggressively.
  2. Use IPFS for storage: Never store large data on-chain.
  3. Testnet extensively: Mainnet mistakes are expensive and permanent.
  4. Security audits are essential: Use OpenZeppelin, get audited before mainnet.
  5. 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.

Share this article

Help others discover this content


Jason Cochran

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.