Building an NFT Marketplace on Base L2
A complete guide to building a gas-efficient NFT marketplace using Base L2, featuring smart contracts, frontend integration, and best practices.

Building an NFT marketplace on Base L2 gives you the perfect combination of Ethereum security and low transaction costs. In this tutorial, we'll build a complete marketplace from scratch.
Why Build on Base L2?
Traditional NFT marketplaces on Ethereum mainnet face significant challenges:
- High gas fees - Listing an NFT can cost $50-200
- Slow transactions - 12-second block times
- Poor UX - Users need to confirm multiple expensive transactions
Base L2 solves all of these problems while maintaining Ethereum's security guarantees.
Cost Comparison
On Base L2, listing an NFT costs less than $0.10, compared to $50-200 on Ethereum mainnet. That's a 500x cost reduction!
Architecture Overview
Our marketplace will have three main components:
- Smart Contracts - NFT contract + Marketplace contract
- Frontend - React app with wagmi for Web3 integration
- Backend (optional) - Indexer for marketplace events
Let's start with the smart contracts.
Step 1: NFT Contract
First, we'll create an ERC-721 contract with enumerable extension for easier marketplace integration:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract BaseNFT is ERC721Enumerable, Ownable {
uint256 private _tokenIdCounter;
string private _baseTokenURI;
mapping(uint256 => string) private _tokenURIs;
constructor(
string memory name,
string memory symbol,
string memory baseURI
) ERC721(name, symbol) Ownable(msg.sender) {
_baseTokenURI = baseURI;
}
function mint(address to, string memory tokenURI) public returns (uint256) {
uint256 tokenId = _tokenIdCounter++;
_safeMint(to, tokenId);
_tokenURIs[tokenId] = tokenURI;
return tokenId;
}
function tokenURI(uint256 tokenId)
public
view
override
returns (string memory)
{
require(_ownerOf(tokenId) != address(0), "Token does not exist");
return string(abi.encodePacked(_baseTokenURI, _tokenURIs[tokenId]));
}
}Gas Optimization
Notice we're using a counter instead of totalSupply() for token IDs. This saves gas by avoiding duplicate SLOAD operations.
Step 2: Marketplace Contract
Now for the marketplace logic with listings, offers, and sales:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract NFTMarketplace is ReentrancyGuard {
struct Listing {
address seller;
address nftContract;
uint256 tokenId;
uint256 price;
bool active;
}
// Listing ID counter
uint256 private _listingIdCounter;
// Mapping from listing ID to Listing
mapping(uint256 => Listing) public listings;
// Platform fee (in basis points, 250 = 2.5%)
uint256 public platformFee = 250;
address public feeRecipient;
event Listed(
uint256 indexed listingId,
address indexed seller,
address indexed nftContract,
uint256 tokenId,
uint256 price
);
event Sold(
uint256 indexed listingId,
address indexed buyer,
uint256 price
);
event ListingCancelled(uint256 indexed listingId);
constructor(address _feeRecipient) {
feeRecipient = _feeRecipient;
}
function createListing(
address nftContract,
uint256 tokenId,
uint256 price
) external returns (uint256) {
require(price > 0, "Price must be greater than 0");
IERC721 nft = IERC721(nftContract);
require(nft.ownerOf(tokenId) == msg.sender, "Not token owner");
require(
nft.isApprovedForAll(msg.sender, address(this)) ||
nft.getApproved(tokenId) == address(this),
"Marketplace not approved"
);
uint256 listingId = _listingIdCounter++;
listings[listingId] = Listing({
seller: msg.sender,
nftContract: nftContract,
tokenId: tokenId,
price: price,
active: true
});
emit Listed(listingId, msg.sender, nftContract, tokenId, price);
return listingId;
}
function buyListing(uint256 listingId)
external
payable
nonReentrant
{
Listing storage listing = listings[listingId];
require(listing.active, "Listing not active");
require(msg.value >= listing.price, "Insufficient payment");
listing.active = false;
// Calculate platform fee
uint256 fee = (listing.price * platformFee) / 10000;
uint256 sellerAmount = listing.price - fee;
// Transfer NFT to buyer
IERC721(listing.nftContract).safeTransferFrom(
listing.seller,
msg.sender,
listing.tokenId
);
// Transfer funds
(bool feeSuccess, ) = feeRecipient.call{value: fee}("");
require(feeSuccess, "Fee transfer failed");
(bool sellerSuccess, ) = listing.seller.call{value: sellerAmount}("");
require(sellerSuccess, "Seller payment failed");
// Refund excess payment
if (msg.value > listing.price) {
(bool refundSuccess, ) = msg.sender.call{
value: msg.value - listing.price
}("");
require(refundSuccess, "Refund failed");
}
emit Sold(listingId, msg.sender, listing.price);
}
function cancelListing(uint256 listingId) external {
Listing storage listing = listings[listingId];
require(listing.seller == msg.sender, "Not seller");
require(listing.active, "Listing not active");
listing.active = false;
emit ListingCancelled(listingId);
}
}The highlighted lines show our gas-optimized ID counter and efficient listing storage.
Step 3: Frontend Integration
Let's build a React component to interact with our marketplace:
'use client';
import { useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther, formatEther } from 'viem';
import { useState } from 'react';
const MARKETPLACE_ADDRESS = '0x...'; // Your deployed address
const MARKETPLACE_ABI = [...]; // Your ABI
export function MarketplaceListing({ listingId }: { listingId: number }) {
const [isPurchasing, setIsPurchasing] = useState(false);
// Read listing data
const { data: listing } = useReadContract({
address: MARKETPLACE_ADDRESS,
abi: MARKETPLACE_ABI,
functionName: 'listings',
args: [BigInt(listingId)],
});
// Write contract for buying
const { writeContract, data: hash } = useWriteContract();
const { isLoading: isConfirming } = useWaitForTransactionReceipt({
hash,
});
const handleBuy = async () => {
if (!listing) return;
setIsPurchasing(true);
try {
await writeContract({
address: MARKETPLACE_ADDRESS,
abi: MARKETPLACE_ABI,
functionName: 'buyListing',
args: [BigInt(listingId)],
value: listing.price,
});
} catch (error) {
console.error('Purchase failed:', error);
} finally {
setIsPurchasing(false);
}
};
if (!listing || !listing.active) {
return <div>Listing not found or inactive</div>;
}
return (
<div className="rounded-lg border p-6">
<h3 className="mb-4 text-xl font-bold">NFT #{listing.tokenId.toString()}</h3>
<p className="mb-4 text-2xl font-bold">
{formatEther(listing.price)} ETH
</p>
<button
onClick={handleBuy}
disabled={isPurchasing || isConfirming}
className="rounded bg-blue-500 px-6 py-2 text-white hover:bg-blue-600 disabled:opacity-50"
>
{isPurchasing || isConfirming ? 'Processing...' : 'Buy Now'}
</button>
</div>
);
}Step 4: Deployment
Deploy to Base Goerli testnet first:
# Using Hardhat
npx hardhat run scripts/deploy.ts --network base-goerli
# Or Foundry
forge create --rpc-url https://goerli.base.org \
--private-key $PRIVATE_KEY \
src/NFTMarketplace.sol:NFTMarketplaceTest First!
Always deploy to Base Goerli testnet first. Use the Base faucet to get test ETH for deployment and testing.
Step 5: Event Indexing
For a production marketplace, you'll want to index events for faster queries:
import { createPublicClient, http, parseAbiItem } from 'viem';
import { base } from 'viem/chains';
const client = createPublicClient({
chain: base,
transport: http(),
});
async function indexListings() {
const logs = await client.getLogs({
address: MARKETPLACE_ADDRESS,
event: parseAbiItem('event Listed(uint256 indexed listingId, address indexed seller, address indexed nftContract, uint256 tokenId, uint256 price)'),
fromBlock: 'earliest',
toBlock: 'latest',
});
// Store in database
for (const log of logs) {
const { listingId, seller, nftContract, tokenId, price } = log.args;
// await db.listings.create({ ... });
}
}Or use OnBase Stream for real-time indexing without the infrastructure overhead:
import { createStream } from '@onbase/stream';
const stream = createStream({
apiKey: process.env.ONBASE_API_KEY,
network: 'base',
});
stream.on('event', {
address: MARKETPLACE_ADDRESS,
event: 'Listed',
callback: async (event) => {
// Real-time listing updates!
await updateDatabase(event.args);
},
});Testing Your Marketplace
import { expect } from 'chai';
import { ethers } from 'hardhat';
describe('NFTMarketplace', function () {
it('Should create and fulfill a listing', async function () {
const [seller, buyer] = await ethers.getSigners();
// Deploy contracts
const NFT = await ethers.getContractFactory('BaseNFT');
const nft = await NFT.deploy('Test', 'TEST', 'ipfs://');
const Marketplace = await ethers.getContractFactory('NFTMarketplace');
const marketplace = await Marketplace.deploy(seller.address);
// Mint NFT
await nft.mint(seller.address, 'token1');
// Approve marketplace
await nft.connect(seller).setApprovalForAll(marketplace.address, true);
// Create listing
const price = ethers.utils.parseEther('1.0');
await marketplace.connect(seller).createListing(nft.address, 0, price);
// Buy listing
await marketplace.connect(buyer).buyListing(0, { value: price });
// Verify ownership transfer
expect(await nft.ownerOf(0)).to.equal(buyer.address);
});
});Production Considerations
Security
- ✅ ReentrancyGuard on all payable functions
- ✅ Approval checks before transfers
- ✅ Input validation on all parameters
- ⚠️ Consider adding pause mechanism for emergencies
- ⚠️ Implement offer expiration to prevent stale listings
Gas Optimization
We've already implemented several optimizations:
- Using counters instead of array length
- Packing struct variables efficiently
- Using
callinstead oftransferfor ETH - Batch operations where possible
User Experience
- Add IPFS integration for metadata
- Implement lazy minting to save gas
- Support ERC-20 tokens as payment
- Add royalty support (EIP-2981)
Next Steps
This is a basic marketplace. In production, you'd want to add features like auctions, offers, bundles, and more sophisticated fee structures.
Deployment Checklist
Before going to mainnet:
- Full test suite coverage
- Security audit completed
- Gas optimization review
- Frontend thoroughly tested
- Backup seller recovery mechanism
- Emergency pause functionality
- Fee recipient configurable
- Events indexed and queryable
Related Resources
Happy building! 🚀
Related Posts

Running an LLM on Orange Pi for Base L2 Development
Learn how to set up and run a private large language model on Orange Pi hardware to build AI-powered dApps on Base L2 with complete data privacy.
Setup your own dedicated Base RPC
Learn how to deploy your own dedicated Base RPC from buying the server to running your wallet or app

Building Subscription Services with Base Recurring Payments
Learn how to implement onchain recurring payments using Base's Spend Permissions feature for subscription-based revenue without merchant fees.