How I Architected an NFT Marketplace on Ethereum
How I Architected an NFT Marketplace on Ethereum
Building HexSea taught me more about distributed systems than any centralized backend project ever did. When you can't just roll back a database transaction, every design decision carries more weight.
The Smart Contract Layer
The marketplace runs on two main contracts: NftMarketplace.sol and a multi-token payment handler. The core operations are:
- List — seller approves the contract to transfer their NFT, then calls
listItem() - Buy — buyer sends ETH or USDC, contract transfers NFT and payment atomically
- Cancel — seller can delist before a sale
The critical insight is that the contract never holds NFTs. It only holds approval. This means:
- The NFT stays in the seller's wallet until the moment of sale
- The seller can use the NFT elsewhere while it's listed
- If the seller transfers or revokes approval, the listing becomes invalid
function listItem(
address nftAddress,
uint256 tokenId,
uint256 price
) external notListed(nftAddress, tokenId) isOwner(nftAddress, tokenId, msg.sender) {
if (price <= 0) revert PriceMustBeAboveZero();
IERC721 nft = IERC721(nftAddress);
if (nft.getApproved(tokenId) != address(this)) {
revert NotApprovedForMarketplace();
}
s_listings[nftAddress][tokenId] = Listing(price, msg.sender);
emit ItemListed(msg.sender, nftAddress, tokenId, price);
}
Multi-Token Payments
Supporting both ETH and USDC required careful thought. I used a proceeds pattern — when a sale goes through, the seller's proceeds are tracked on-chain, and they withdraw separately. This avoids re-entrancy attacks that plague push-payment patterns.
For USDC (an ERC-20), the buyer calls approve() on the token contract first, then buyItemWithToken() on the marketplace. The contract uses safeTransferFrom to pull the tokens.
Indexing with The Graph
Reading data from the blockchain is expensive and slow. You can't query "show me all NFTs listed for less than 1 ETH" from a smart contract directly. The Graph solves this.
I wrote a subgraph that indexes every ItemListed, ItemBought, and ItemCanceled event. The Graph nodes listen for these events and store them in a queryable GraphQL schema:
type ActiveItem @entity {
id: ID!
buyer: String!
seller: String!
nftAddress: String!
tokenId: BigInt!
price: BigInt!
}
The frontend then queries this subgraph to render the marketplace in milliseconds, not seconds.
IPFS for Metadata
NFT metadata (name, image, attributes) lives on IPFS — a content-addressed distributed filesystem. When you mint an NFT, the token URI points to an IPFS hash, not a centralized server. This means the metadata can't be changed or taken down by any single party.
I used Pinata to pin metadata to IPFS, ensuring it stays available even if my own node goes offline.
The Frontend
Next.js was the right call here. The marketplace page uses server-side queries to The Graph subgraph for fast initial loads, then hydrates to a client-side state for real-time updates. Wagmi handles wallet connections (MetaMask, WalletConnect) and contract interactions.
const { writeContract } = useWriteContract();
const handleBuy = async () => {
writeContract({
address: MARKETPLACE_ADDRESS,
abi: marketplaceAbi,
functionName: "buyItem",
args: [nftAddress, tokenId],
value: parseEther(price),
});
};
What I'd Do Differently
- Upgradeable contracts from the start — using a proxy pattern would have let me fix bugs without redeploying and migrating state.
- More comprehensive tests — I wrote Hardhat tests, but coverage of edge cases could be better. One missed edge case on mainnet can be catastrophic.
- Gas optimization earlier — I profiled gas costs late in development and had to refactor storage patterns.
The live demo is at hexsea.vercel.app and the contracts are verified on Etherscan if you want to read the source.