BlockchainAI Engineering14 min readUpdated

Create an ERC721 NFT: Remix Step by Step

By Mudassir Khan — Agentic AI Consultant & AI Systems Architect, Islamabad, Pakistan

Cover illustration for: Create an ERC721 NFT: Remix Step by Step

Section 01 · Definition

What is an ERC721 NFT, exactly?

ERC721 is the standard interface for non fungible tokens on Ethereum. Non fungible means each token in the collection is unique and not interchangeable with the others.

Quick answer

What is ERC721? ERC721 is a standard interface defined in EIP 721 for non fungible tokens on Ethereum and EVM compatible chains. Each token has a unique 256 bit tokenId, a single owner at any time, and optional metadata describing what the token represents. CryptoPunks, Bored Apes, Azuki, ENS domains, and most digital art collections are ERC721.

The contrast with ERC20 is exact. ERC20 tracks balances. ERC721 tracks individual tokens by id. In ERC20, your balance is a single number. In ERC721, your balance is a count of distinct ids you happen to own at the moment. You can own tokenId 42 of one collection and tokenId 99 of another; the contracts and the ids are independent.

ERC721 was finalised in 2018 by William Entriken, Dieter Shirley, Jacob Evans, and Nastassia Sachs. It built on the lessons of CryptoKitties, the first widely adopted NFT collection, and standardised the interface so every wallet and marketplace could integrate any NFT contract without custom code. The standard ships in three pieces: the core IERC721, the optional IERC721Metadata extension (name, symbol, tokenURI), and the optional IERC721Enumerable extension (iterate the full collection on chain).

The image is not on chain

A common misconception is that NFTs store the artwork on the blockchain. In almost every case they do not. The contract stores ownership and a tokenURI string. The URI points to a JSON file that points to an image file. Both files live off chain on IPFS, Arweave, or a regular web server. If those files disappear, the NFT still exists on chain but has no picture to show.

Section 02 · Interface

The complete ERC721 interface

Six required functions in the core, three optional metadata functions everyone implements, plus the supportsInterface check inherited from ERC165.

The official interface, written exactly as the EIP defines it, looks like this:

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

interface IERC165 {
    function supportsInterface(bytes4 interfaceId) external view returns (bool);
}

interface IERC721 is IERC165 {
    // ── Required events ─────────────────────────────────────
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

    // ── Required view functions ─────────────────────────────
    function balanceOf(address owner) external view returns (uint256 balance);
    function ownerOf(uint256 tokenId) external view returns (address owner);
    function getApproved(uint256 tokenId) external view returns (address operator);
    function isApprovedForAll(address owner, address operator) external view returns (bool);

    // ── Required state changing functions ───────────────────
    function approve(address to, uint256 tokenId) external;
    function setApprovalForAll(address operator, bool approved) external;
    function transferFrom(address from, address to, uint256 tokenId) external;
    function safeTransferFrom(address from, address to, uint256 tokenId) external;
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
}

interface IERC721Metadata is IERC721 {
    function name()    external view returns (string memory);
    function symbol()  external view returns (string memory);
    function tokenURI(uint256 tokenId) external view returns (string memory);
}
Six numbered cards listing the ERC721 interface functions: ownerOf, balanceOf, transferFrom, safeTransferFrom, approve, and setApprovalForAll.
The six headline functions. balanceOf and ownerOf are reads; the other four mutate state and emit events.

ownerOf(tokenId)

Returns the wallet address that owns a specific token. Reverts if the id has never been minted or has been burned. This is the canonical way to look up the current owner of any NFT in the collection.

balanceOf(owner)

Returns how many NFTs the given address owns inside this collection. Note: ERC721 balanceOf reverts on the zero address, unlike ERC20 which simply returns zero.

transferFrom(from, to, tokenId)

Moves a specific tokenId from from to to. The caller must be the owner, an approved spender for that token, or an operator approved for all of from's tokens. Emits a Transfer event. This function does not check whether the receiver can handle NFTs, so use safeTransferFrom for sending to contracts.

safeTransferFrom(from, to, tokenId)

Same as transferFrom plus a safety check. After the transfer, if to is a contract, the function calls to.onERC721Received and reverts if the contract does not return the magic acknowledgement value. This prevents NFTs from being sent to contracts that have no way to move them out, where they would be stuck forever.

approve(to, tokenId)

Grants a single address permission to transfer one specific tokenId on behalf of the owner. The approval is automatically cleared when the token is transferred, which differs from ERC20 where allowance persists. Emits an Approval event.

setApprovalForAll(operator, bool)

Approves or revokes an operator's permission to manage every NFT the caller owns in this collection. This is the function marketplaces like OpenSea call: you approve the marketplace contract once, and it can then transfer any of your NFTs from that collection until you revoke. Emits ApprovalForAll.

Section 03 · Metadata

Metadata: how the picture appears in your wallet

The contract stores ownership and a URI. Wallets and marketplaces follow that URI to a JSON manifest, fetch the linked image, and render the result.

Two by two grid showing the four ERC721 metadata layers: tokenURI on chain, JSON manifest off chain, image asset on storage, and the attributes trait array.
The four layers. The contract is the only piece on chain; the JSON, image, and attributes live wherever the URI points.

A typical metadata JSON file follows the OpenSea standard, which has become the de facto schema:

json
{
  "name": "Sample NFT #1",
  "description": "A small example NFT minted on Remix to demonstrate the ERC721 metadata standard.",
  "image": "ipfs://QmYourImageCID/1.png",
  "external_url": "https://yoursite.com/nft/1",
  "attributes": [
    { "trait_type": "Background", "value": "Blue" },
    { "trait_type": "Eyes",       "value": "Lasers" },
    { "trait_type": "Power",      "value": 95 }
  ]
}

Pin this JSON on IPFS using a service like Pinata, NFT.Storage, or web3.storage. You get back a content identifier (CID) that looks like QmRJSomething. The full URI then becomes ipfs://QmRJSomething/1.json. When you mint tokenId 1, you store this URI on chain. Wallets gateway through ipfs.io or use their own IPFS client to fetch it, parse the JSON, fetch the image, and render the NFT.

Section 04 · Build Your Own

Write your own ERC721 from scratch

A complete, working NFT contract in roughly 110 lines. Read it once; every line implements one slice of the standard.

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

interface IERC721Receiver {
    function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
        external returns (bytes4);
}

contract MyNFT {
    // ── Metadata ────────────────────────────────────────────
    string public name;
    string public symbol;

    // ── Core state ──────────────────────────────────────────
    uint256 public nextTokenId;
    address public owner;

    mapping(uint256 => address) private _owners;
    mapping(address => uint256) private _balances;
    mapping(uint256 => address) private _tokenApprovals;
    mapping(address => mapping(address => bool)) private _operatorApprovals;
    mapping(uint256 => string)  private _tokenURIs;

    // ── Events ──────────────────────────────────────────────
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);

    // ── Errors ──────────────────────────────────────────────
    error NotOwner();
    error NotAuthorized();
    error TokenDoesNotExist();
    error TransferToZero();
    error UnsafeRecipient();

    constructor(string memory _name, string memory _symbol) {
        name   = _name;
        symbol = _symbol;
        owner  = msg.sender;
    }

    // ── Read functions ──────────────────────────────────────
    function ownerOf(uint256 tokenId) public view returns (address) {
        address o = _owners[tokenId];
        if (o == address(0)) revert TokenDoesNotExist();
        return o;
    }

    function balanceOf(address account) external view returns (uint256) {
        if (account == address(0)) revert TransferToZero();
        return _balances[account];
    }

    function getApproved(uint256 tokenId) external view returns (address) {
        if (_owners[tokenId] == address(0)) revert TokenDoesNotExist();
        return _tokenApprovals[tokenId];
    }

    function isApprovedForAll(address holder, address operator) external view returns (bool) {
        return _operatorApprovals[holder][operator];
    }

    function tokenURI(uint256 tokenId) external view returns (string memory) {
        if (_owners[tokenId] == address(0)) revert TokenDoesNotExist();
        return _tokenURIs[tokenId];
    }

    // ── Mint ────────────────────────────────────────────────
    function mint(address to, string calldata uri) external returns (uint256) {
        if (msg.sender != owner) revert NotOwner();
        if (to == address(0))    revert TransferToZero();

        uint256 id = nextTokenId++;
        _owners[id]     = to;
        _balances[to]   += 1;
        _tokenURIs[id]  = uri;
        emit Transfer(address(0), to, id);
        return id;
    }

    // ── Approve ─────────────────────────────────────────────
    function approve(address to, uint256 tokenId) external {
        address o = ownerOf(tokenId);
        if (msg.sender != o && !_operatorApprovals[o][msg.sender]) revert NotAuthorized();
        _tokenApprovals[tokenId] = to;
        emit Approval(o, to, tokenId);
    }

    function setApprovalForAll(address operator, bool approved) external {
        _operatorApprovals[msg.sender][operator] = approved;
        emit ApprovalForAll(msg.sender, operator, approved);
    }

    // ── Transfer ────────────────────────────────────────────
    function transferFrom(address from, address to, uint256 tokenId) public {
        _transfer(from, to, tokenId);
    }

    function safeTransferFrom(address from, address to, uint256 tokenId) external {
        safeTransferFrom(from, to, tokenId, "");
    }

    function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory data) public {
        _transfer(from, to, tokenId);
        if (to.code.length > 0) {
            try IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, data) returns (bytes4 ret) {
                if (ret != IERC721Receiver.onERC721Received.selector) revert UnsafeRecipient();
            } catch {
                revert UnsafeRecipient();
            }
        }
    }

    // ── Internal ────────────────────────────────────────────
    function _transfer(address from, address to, uint256 tokenId) internal {
        if (to == address(0))            revert TransferToZero();
        address o = ownerOf(tokenId);
        if (from != o)                   revert NotAuthorized();
        if (msg.sender != o
            && _tokenApprovals[tokenId] != msg.sender
            && !_operatorApprovals[o][msg.sender]) revert NotAuthorized();

        delete _tokenApprovals[tokenId];
        _balances[from] -= 1;
        _balances[to]   += 1;
        _owners[tokenId] = to;
        emit Transfer(from, to, tokenId);
    }
}

The structure mirrors the standard exactly. Three mappings track ownership: _owners (id to address), _balances (address to count), and _tokenURIs (id to metadata pointer). Two approval mappings track who can move what: per token approvals and operator approvals for the entire collection. The safe transfer hook calls back into the receiver and reverts if the receiver does not acknowledge.

Vertical flow showing the ERC721 mint lifecycle from a wallet calling mint, the contract running internal mint, and the resulting on chain ownership and Transfer event.
The mint lifecycle. _safeMint emits Transfer from the zero address, which is how indexers detect new tokens.

For production, the OpenZeppelin variant is shorter and audited:

solidity
// 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";

contract MyNFT is ERC721URIStorage, Ownable {
    uint256 public nextTokenId;

    constructor(string memory name_, string memory symbol_)
        ERC721(name_, symbol_)
        Ownable(msg.sender)
    {}

    function mint(address to, string calldata uri) external onlyOwner returns (uint256) {
        uint256 id = nextTokenId++;
        _safeMint(to, id);
        _setTokenURI(id, uri);
        return id;
    }
}

Section 05 · Remix Deploy

Deploy your NFT on Remix

Remix handles compile, deploy, and call. The same flow scales from sandbox VM to Sepolia testnet to mainnet.

Open Remix and create MyNFT.sol

Visit remix.ethereum.org. Create a new file inside the contracts folder. Paste either the from scratch contract or the OpenZeppelin variant. If you use OpenZeppelin imports, Remix automatically resolves them to the latest version through its npm shim.

Compile with solc 0.8.20

Switch to the Solidity Compiler tab. Pick version 0.8.20 or any compatible 0.8 release. Click Compile. A green check next to the file name means the build succeeded. Read any warnings carefully; warnings about unused variables or shadowed names usually point to bugs.

Pin metadata on IPFS

Before deploying, pin a sample image to a service like nft.storage or pinata.cloud. Then create a JSON manifest like the one shown earlier and pin that too. Note the resulting ipfs:// URI; you will pass it to the mint function as the tokenURI argument.

Deploy to Remix VM, then to Sepolia

First deploy to Remix VM by selecting it in the Environment dropdown. Enter the constructor name and symbol arguments and click Deploy. Test mint, transfer, and ownerOf calls from the deployed contract panel. When everything works, switch the Environment to Injected Provider with Sepolia in MetaMask, and deploy again to the public testnet.

Mint your first NFT

Expand the deployed contract panel. Find the mint function. Enter your wallet address and the IPFS URI you pinned earlier. Click transact, sign in MetaMask, and wait for confirmation. The Remix terminal logs the Transfer event with from set to the zero address (the mint signature) and to set to your wallet.

View it in MetaMask and OpenSea

In MetaMask, open the NFTs tab and tap Import NFT. Enter the contract address and tokenId 0. The image and metadata appear within seconds. On Sepolia, the same contract is already visible at testnets.opensea.io once it has any minted tokens. Mainnet works identically; OpenSea picks up new collections automatically.

Section 06 · Pitfalls

Common ERC721 mistakes to avoid

The standard is well audited but the ecosystem has accumulated a long list of mistakes that look fine at first.

Using transferFrom to send NFTs to contracts

transferFrom does not check whether the receiver can handle NFTs. If you send to a contract that has no transfer function, the NFT is stuck forever. Always use safeTransferFrom when the destination might be a contract. End users sending to other end users with transferFrom is fine.

Hosting metadata on a server you control

If you host the JSON or image on a regular web server and the server goes down, the NFT renders blank everywhere. Pin to IPFS through a managed service, mirror to Arweave, or use both. The image and the JSON should both be content addressed.

Mutable token URIs without telling users

Some contracts let the owner change the tokenURI for any token after mint. This breaks the implicit promise that the metadata is immutable. If the URI must be mutable, document it clearly and emit a MetadataUpdate event so indexers can refresh.

Using sequential IDs that leak the mint count

If your IDs go 1, 2, 3, anyone can compute exactly how many tokens have been minted. For traits revealed before all mints, this can leak rarity information. Some collections randomise the ID space at reveal time using a commit reveal scheme.

Section 07 · FAQ

Frequently asked questions

What is the difference between ERC721 and ERC1155?

ERC721 is one contract per collection, where every token has a unique id and a unique owner. ERC1155 is a multi token standard: a single contract holds many different token types, each with its own id and possibly multiple copies. Use ERC721 when each item is unique. Use ERC1155 when you need both fungible and non fungible items in the same contract or when you want batch transfers.

Where should I store the NFT image?

Use IPFS through a pinning service like NFT.Storage, Pinata, or web3.storage. Optionally mirror to Arweave for permanent storage. Avoid storing on a regular web server because if the server goes offline the metadata stops loading. Some collections embed the image directly on chain using SVG or base64 data URIs; this is the most permanent option but only works for tiny images.

How do royalties work on NFTs?

ERC721 itself has no royalty support. The ERC2981 standard adds an optional royaltyInfo function that returns a recipient and a percentage for each sale. Marketplaces like OpenSea and Magic Eden voluntarily honour ERC2981 most of the time, though some marketplaces have made royalties optional. For enforced royalties, you need additional logic like restricted operators (ERC721C) or an allow list of approved marketplaces.

Can the contract owner take back NFTs after minting?

Only if you explicitly write that capability into the contract. The from scratch contract above does not include a clawback function. The standard ERC721 transfer rules require either ownership, approval, or operator approval, so a separate owner cannot move tokens without that approval. Adding a clawback or burn function is technically possible but breaks the promise of true ownership.

How much does it cost to deploy and mint NFTs?

On Ethereum mainnet, deploying a typical ERC721 costs $20 to $200 depending on contract size and gas prices. Each mint costs $5 to $50 depending on storage. On Layer 2 networks like Base or Arbitrum, both costs drop to a few cents. On Sepolia testnet everything is free. Most new NFT projects launch on a Layer 2 first to keep mint costs accessible.

Do I need to verify my contract on Etherscan?

It is not required, but it is the strong convention. Verified contracts show readable Solidity source on Etherscan, expose Read and Write tabs that let users interact through the explorer, and signal seriousness to buyers. Remix has a Sourcify and Etherscan plugin that handles verification with a couple of clicks once you have an Etherscan API key.

Written by Mudassir Khan

Agentic AI consultant and AI systems architect based in Islamabad, Pakistan. CEO of Cube A Cloud. 38+ agentic AI launches delivered for global founders and CTOs.

View agentic AI consulting serviceSee ChainTrust case study

Related service

Agentic AI Consulting

See scope & pricing →

Related case study

ChainTrust Compliance Engine

Read case study →

More on this topic

Need an AI systems architect?

Book a 30-minute architecture call. I will sketch the high-level design for your use case and give you an honest view of the trade-offs.

Book a strategy call →