March 28, 2026·10 min·By

Build an NFT Marketplace with Solidity and Next.js

NFTERC-721SolidityWeb3marketplaceNext.js

NFT marketplaces look complex from the outside. They're really just a set of well-understood contracts. Here's the full implementation.

The Contracts

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract ArtNFT is ERC721URIStorage, Ownable {
    uint256 private _tokenIds;
    uint96 public royaltyBps;  // Basis points (250 = 2.5%)
    address public royaltyRecipient;

    mapping(uint256 => address) public creators;

    event Minted(uint256 indexed tokenId, address indexed creator, string tokenURI);

    constructor(address owner, uint96 _royaltyBps)
        ERC721("Art NFT", "ART")
        Ownable(owner)
    {
        royaltyBps = _royaltyBps;
        royaltyRecipient = owner;
    }

    function mint(string calldata tokenURI) external returns (uint256) {
        _tokenIds++;
        uint256 tokenId = _tokenIds;

        _mint(msg.sender, tokenId);
        _setTokenURI(tokenId, tokenURI);
        creators[tokenId] = msg.sender;

        emit Minted(tokenId, msg.sender, tokenURI);
        return tokenId;
    }

    // EIP-2981 royalty standard
    function royaltyInfo(uint256, uint256 salePrice)
        external view returns (address, uint256)
    {
        return (royaltyRecipient, (salePrice * royaltyBps) / 10000);
    }
}

The Marketplace Contract

contract Marketplace {
    struct Listing {
        address seller;
        address nftContract;
        uint256 tokenId;
        uint256 price;
        bool active;
    }

    mapping(bytes32 => Listing) public listings;
    uint256 public platformFeeBps = 250;  // 2.5% platform fee
    address public feeRecipient;

    event Listed(bytes32 indexed listingId, address indexed seller, address nftContract, uint256 tokenId, uint256 price);
    event Sold(bytes32 indexed listingId, address indexed buyer, uint256 price);
    event Cancelled(bytes32 indexed listingId);

    constructor(address _feeRecipient) {
        feeRecipient = _feeRecipient;
    }

    function list(address nftContract, uint256 tokenId, uint256 price) external {
        require(price > 0, "Price must be > 0");

        IERC721 nft = IERC721(nftContract);
        require(nft.ownerOf(tokenId) == msg.sender, "Not owner");
        require(
            nft.isApprovedForAll(msg.sender, address(this)) ||
            nft.getApproved(tokenId) == address(this),
            "Marketplace not approved"
        );

        bytes32 listingId = keccak256(abi.encodePacked(nftContract, tokenId, msg.sender));
        listings[listingId] = Listing({
            seller: msg.sender,
            nftContract: nftContract,
            tokenId: tokenId,
            price: price,
            active: true
        });

        emit Listed(listingId, msg.sender, nftContract, tokenId, price);
    }

    function buy(bytes32 listingId) external payable nonReentrant {
        Listing storage listing = listings[listingId];
        require(listing.active, "Not active");
        require(msg.value == listing.price, "Wrong price");

        listing.active = false;

        uint256 platformFee = (msg.value * platformFeeBps) / 10000;

        // Check for EIP-2981 royalties
        uint256 royaltyAmount = 0;
        try IERC2981(listing.nftContract).royaltyInfo(listing.tokenId, msg.value) returns (
            address royaltyRecipient, uint256 royaltyValue
        ) {
            royaltyAmount = royaltyValue;
            payable(royaltyRecipient).transfer(royaltyAmount);
        } catch {}

        uint256 sellerProceeds = msg.value - platformFee - royaltyAmount;
        payable(feeRecipient).transfer(platformFee);
        payable(listing.seller).transfer(sellerProceeds);

        IERC721(listing.nftContract).safeTransferFrom(
            listing.seller, msg.sender, listing.tokenId
        );

        emit Sold(listingId, msg.sender, msg.value);
    }

    function cancel(bytes32 listingId) external {
        Listing storage listing = listings[listingId];
        require(listing.seller == msg.sender, "Not seller");
        require(listing.active, "Not active");
        listing.active = false;
        emit Cancelled(listingId);
    }
}

Next.js Frontend

'use client'
import { useReadContract, useWriteContract } from 'wagmi'
import { parseEther, formatEther } from 'viem'

const MARKETPLACE_ADDRESS = '0x...' as const
const NFT_ADDRESS = '0x...' as const

export function ListingCard({ listingId }: { listingId: `0x${string}` }) {
    const { data: listing } = useReadContract({
        address: MARKETPLACE_ADDRESS,
        abi: marketplaceAbi,
        functionName: 'listings',
        args: [listingId]
    })

    const { writeContract, isPending } = useWriteContract()

    const buy = () => {
        if (!listing) return
        writeContract({
            address: MARKETPLACE_ADDRESS,
            abi: marketplaceAbi,
            functionName: 'buy',
            args: [listingId],
            value: listing.price
        })
    }

    if (!listing?.active) return null

    return (
        <div className="card">
            <NFTImage tokenId={listing.tokenId} contract={listing.nftContract} />
            <p className="price">{formatEther(listing.price)} ETH</p>
            <button onClick={buy} disabled={isPending}>
                {isPending ? 'Buying...' : 'Buy Now'}
            </button>
        </div>
    )
}

Minting with IPFS Metadata

import { ThirdwebStorage } from "@thirdweb-dev/storage"

const storage = new ThirdwebStorage()

async function mintWithMetadata(
    name: string,
    description: string,
    imageFile: File
): Promise<string> {
    // Upload image to IPFS
    const imageUri = await storage.upload(imageFile)

    // Upload metadata JSON to IPFS
    const metadataUri = await storage.upload({
        name,
        description,
        image: imageUri,
        attributes: [
            { trait_type: "Artist", value: "My Name" }
        ]
    })

    // Mint with IPFS URI
    const { writeContract } = useWriteContract()
    writeContract({
        address: NFT_ADDRESS,
        abi: nftAbi,
        functionName: 'mint',
        args: [metadataUri]
    })

    return metadataUri
}

NFT marketplaces at their core are simple: an NFT contract, a marketplace contract that holds approvals and handles payment splitting, and a frontend to interact with them. The complexity comes from UX polish and handling edge cases (failed transfers, royalty calculations, gas estimation).

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

When should I use Server Components vs Client Components?+

Server Components are the default — use them for data fetching, static content, and anything that doesn't need interactivity. Only switch to Client Components when you need browser APIs, state, effects, or event handlers. The less JS you ship to the browser, the faster your site.

Why is my Next.js build so slow?+

Usually it's TypeScript type-checking or a large dependency. Try `NEXT_TELEMETRY_DISABLED=1 next build` with `--profile` and look at what's taking time. Splitting large data files and enabling turborepo are the two biggest wins.

Should I use the App Router or Pages Router for a new project?+

App Router for new projects, full stop. Pages Router still works fine but receives fewer improvements. The learning curve for App Router is steep for one week, then you never want to go back.

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation