Build an NFT Minting DApp

Deploy an ERC-721 smart contract and build a Next.js minting frontend.

Introduction

30 Second Summary

NFTs changed how digital ownership works. Every profile picture collection, digital art marketplace, and membership token relies on the same smart contract standard under the hood.

In this project, you will build a professional NFT minting website from scratch. You'll write a Solidity smart contract, deploy it to a test blockchain, and connect it to a Next.js frontend where users can mint and view NFTs.

What You'll Build

A full-stack NFT minting DApp with a custom ERC-721 smart contract on Sepolia testnet and a Next.js frontend featuring wallet connection, minting, and a gallery of minted tokens.

By the end of this project, you'll have:

  • A Solidity smart contract using OpenZeppelin's ERC-721 standard with mint functionality, supply cap, and token URI storage.
  • A Hardhat project with deployment scripts targeting Sepolia testnet.
  • A Next.js frontend with MetaMask wallet connection, mint button, transaction feedback, and an NFT gallery page.
  • Secret Mission: A quantity selector and live progress bar showing total supply vs max supply.

Are there any prerequisites?

You should have basic JavaScript or TypeScript knowledge, a basic understanding of React, and familiarity with the command line. No prior blockchain or Web3 experience is required.

Create the Hardhat Project and Smart Contract

Your environment is ready with Node.js, MetaMask, and Sepolia test ETH. Now it's time to build the core of your DApp: the smart contract.

Smart contracts are programs that live on the blockchain and execute automatically when someone calls their functions. You'll use Hardhat to create, compile, and deploy your contract. The contract itself follows the ERC-721 standard, which is the universal format for NFTs on Ethereum.

In this step, get ready to:
  • Initialize a Hardhat 3 project with TypeScript support.
  • Install OpenZeppelin Contracts for battle-tested ERC-721 building blocks.
  • Write the NextNFT smart contract with minting, supply tracking, and access control.
Initialize the Hardhat project

Hardhat is a development environment for Ethereum smart contracts. It handles compilation, testing, and deployment so you can focus on writing Solidity code.

  • Open your terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir nft-project
cd nft-project
  • Initialize your Hardhat project by running:
npx hardhat --init

What happens when you run this?

Hardhat will prompt you with a few configuration options. Accept the default answers to quickly create a working setup. This installs all required dependencies and creates the project structure automatically.

The default template uses the Viem toolbox with TypeScript. The initialization may take a minute while it installs packages.

  • Confirm the project was created by running:
ls

You should see package.json, hardhat.config.ts, contracts/, test/, and ignition/ in the output.

Command not found or permission error?

Make sure Node.js v22+ is installed by running node --version. Hardhat 3 requires Node.js v22.13.0 or later. If you see a version below that, revisit Step 1 to upgrade.

help me fix a Hardhat initialization error

Install OpenZeppelin Contracts

OpenZeppelin provides audited, reusable smart contract components. Instead of writing an ERC-721 token from scratch, you'll inherit from their battle-tested implementation.

  • Install the OpenZeppelin Contracts package by running:
npm install @openzeppelin/contracts

Why use OpenZeppelin?

Writing token standards from scratch is error-prone and dangerous. A single bug in an NFT contract can mean tokens get permanently locked or stolen. OpenZeppelin contracts are audited by security professionals and used by thousands of production projects.

You should see the package added to your package.json dependencies after the install completes.

npm install failing?

Make sure you're inside the nft-project folder. Run pwd to check your current directory. If you see a network error, check your internet connection and try again.

help me fix npm install errors for OpenZeppelin

Write the NextNFT smart contract

Now for the main event. You'll write a smart contract that mints NFTs, tracks total supply, enforces a maximum supply cap, and requires payment per mint.

  • Delete the sample contracts that Hardhat created by running:
rm contracts/Counter.sol contracts/Counter.t.sol
  • Create a new file called NextNFT.sol inside the contracts/ folder. In VS Code, right-click the contracts folder in the Explorer sidebar and select New File.
  • Name it NextNFT.sol and press Enter.

You'll build this contract in three chunks. Start with the imports, contract declaration, and constructor.

  • Paste the following code into NextNFT.sol to set up the contract foundation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

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

contract NextNFT is ERC721, ERC721URIStorage, Ownable {
    uint256 private _nextTokenId;
    uint256 public maxSupply;
    uint256 public mintPrice;

    constructor(
        uint256 _maxSupply,
        uint256 _mintPrice
    ) ERC721("NextNFT", "NNFT") Ownable(msg.sender) {
        maxSupply = _maxSupply;
        mintPrice = _mintPrice;
    }
}

What does this code do?

The import statements pull in OpenZeppelin's ERC-721 implementation, URI storage extension (for metadata links), and Ownable access control.

The contract inherits from all three. _nextTokenId tracks the next token ID to mint. maxSupply caps how many NFTs can ever exist. mintPrice sets the cost per mint in wei.

The constructor calls ERC721("NextNFT", "NNFT") to set the token name and symbol, and Ownable(msg.sender) to make the deployer the contract owner.

  • Add the core minting function below the constructor, inside the contract braces. Paste this code after the closing } of the constructor:
    function mint(string memory uri) public payable {
        require(_nextTokenId < maxSupply, "Max supply reached");
        require(msg.value >= mintPrice, "Insufficient payment");

        uint256 tokenId = _nextTokenId;
        _nextTokenId++;
        _safeMint(msg.sender, tokenId);
        _setTokenURI(tokenId, uri);
    }

What does the mint function do?

The payable keyword means this function can receive ETH. The two require statements act as guards: the first checks that the supply cap hasn't been reached, and the second checks the caller sent enough ETH.

If both checks pass, the function assigns the current _nextTokenId, increments the counter, mints the NFT to the caller's address with _safeMint, and stores the metadata URI on-chain with _setTokenURI.

  • Add the supporting functions below the mint function. These handle supply queries, fund withdrawal, and required Solidity overrides:
    function totalSupply() public view returns (uint256) {
        return _nextTokenId;
    }

    function withdraw() public onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }

    // 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);
    }

What do these functions do?

The totalSupply() function returns how many NFTs have been minted so far. The withdraw() function lets the contract owner collect all the ETH that buyers paid during minting. The onlyOwner modifier restricts it to the deployer.

The tokenURI and supportsInterface overrides are required by Solidity because both ERC721 and ERC721URIStorage define these functions. The overrides tell the compiler which version to use (ERC721URIStorage's, via super).

  • Save NextNFT.sol.
  • Make sure the final closing } brace at the bottom of the file closes the contract NextNFT declaration.

Your contract is complete. You won't see a visible change yet because the next step handles compilation and testing.

Seeing red underlines in VS Code?

Red squiggles are normal before compilation. Make sure your closing brace } matches the opening of contract NextNFT. Check that you didn't accidentally paste a function outside the contract braces.

help me debug red underlines in my Solidity contract

✔️ Awesome, I've got everything!

Great. Double check you've saved NextNFT.sol before moving on.

ⓧ I'd like to double check the full code

Here's the complete contracts/NextNFT.sol file. Make sure yours matches exactly:

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

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

contract NextNFT is ERC721, ERC721URIStorage, Ownable {
    uint256 private _nextTokenId;
    uint256 public maxSupply;
    uint256 public mintPrice;

    constructor(
        uint256 _maxSupply,
        uint256 _mintPrice
    ) ERC721("NextNFT", "NNFT") Ownable(msg.sender) {
        maxSupply = _maxSupply;
        mintPrice = _mintPrice;
    }

    function mint(string memory uri) public payable {
        require(_nextTokenId < maxSupply, "Max supply reached");
        require(msg.value >= mintPrice, "Insufficient payment");

        uint256 tokenId = _nextTokenId;
        _nextTokenId++;
        _safeMint(msg.sender, tokenId);
        _setTokenURI(tokenId, uri);
    }

    function totalSupply() public view returns (uint256) {
        return _nextTokenId;
    }

    function withdraw() public onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }

    // 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);
    }
}

Your smart contract is written and ready. Next up, you'll compile it, write a test to verify minting works, and deploy it to the Sepolia testnet.

Test and Deploy the Smart Contract

Your smart contract is written, but code that hasn't been tested is code you can't trust. Before spending real (test) ETH to deploy, you need to verify your contract works locally.

In this step, you'll write a test to confirm minting works, configure your project for the Sepolia testnet, and deploy your contract to a live blockchain.

In this step, get ready to:
  • Write and run a test to verify minting logic.
  • Configure Hardhat for the Sepolia testnet.
  • Deploy the contract to Sepolia using Hardhat Ignition.
Write a basic test to verify minting works

Tests run your contract in a simulated blockchain environment so you can confirm its logic before paying for real transactions.

Hardhat 3 uses the Node.js test runner with viem for type-safe contract interactions in tests.

  • Create a new file called NextNFT.ts inside the test/ folder of your nft-project directory.
  • Add the following test code to test/NextNFT.ts:
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import hre from "hardhat";

// Create a fresh simulated blockchain for testing
const { viem } = await hre.network.create();

describe("NextNFT", function () {
  it("Should mint an NFT and increment total supply", async function () {
    // Deploy the contract with maxSupply=1000 and mintPrice=0.00001 ETH
    const nextNFT = await viem.deployContract("NextNFT", [
      1000n,
      10000000000000n,
    ]);

    // Verify initial total supply is 0
    const initialSupply = await nextNFT.read.totalSupply();
    assert.equal(initialSupply, 0n);

    // Mint an NFT by sending the required payment
    await nextNFT.write.mint(["ipfs://test-uri"], {
      value: 10000000000000n,
    });

    // Verify total supply increased to 1
    const newSupply = await nextNFT.read.totalSupply();
    assert.equal(newSupply, 1n);
  });
});

What does this test do?

The test deploys a fresh instance of your NextNFT contract in a local simulated blockchain. It checks that totalSupply() starts at 0, calls mint() with the correct payment, and confirms the supply incremented to 1.

The hre.network.create() call spins up a fresh simulated Ethereum network. No real ETH is involved.

  • Save test/NextNFT.ts.
  • Run your test by executing this command:
npx hardhat test

You should see output indicating 1 test passing with a green checkmark.

Test failing or not found?

  • Make sure the file is saved as test/NextNFT.ts (not in a subfolder or with a typo in the name).
  • Check that contracts/NextNFT.sol is saved with no syntax errors.
  • If you see a compilation error, verify your Solidity pragma matches ^0.8.24 and that @openzeppelin/contracts is installed.

Why is my Hardhat test failing?

Configure Hardhat for the Sepolia network

To deploy on a live testnet, Hardhat needs two things: an RPC endpoint (a URL that connects to the Sepolia network) and a private key (to sign deployment transactions from your wallet).

  • Sign up for a free account at Alchemy (or Infura) and create a new app targeting the Sepolia network.
  • Copy the HTTPS RPC URL from your Alchemy dashboard. It looks like https://eth-sepolia.g.alchemy.com/v2/your-api-key.
  • Get your MetaMask private key by opening MetaMask, clicking the three dots menu next to your account, selecting Account details, then clicking Show private key.

Never share your private key

Your private key controls your wallet. Only use a testnet-only wallet for development. Never commit private keys to version control or share them with anyone.

  • Create a file called .env in the root of your nft-project folder to store your credentials:
SEPOLIA_RPC_URL=your-alchemy-sepolia-url-here
PRIVATE_KEY=your-metamask-private-key-here

Why a .env file?

The .env file keeps sensitive values out of your source code. You load these as environment variables before deploying. Never commit this file to Git.

  • Update your hardhat.config.ts to add the Sepolia network configuration. Replace the entire contents of the file with this:
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox-viem";

const config: HardhatUserConfig = {
  solidity: "0.8.24",
  networks: {
    sepolia: {
      url: process.env.SEPOLIA_RPC_URL || "",
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
    },
  },
};

export default config;

What does this configuration do?

The networks.sepolia block tells Hardhat how to connect to the Sepolia testnet. It reads the RPC URL and private key from environment variables at runtime.

The solidity: "0.8.24" field sets the Solidity compiler version to match your contract's pragma.

  • Save hardhat.config.ts.

Config not loading environment variables?

The process.env values must be set in your terminal session before you run the deploy command. You will export them in the deployment substep below.

Why is Hardhat not picking up my environment variables?

Deploy the contract to Sepolia using Hardhat Ignition

Hardhat Ignition is a declarative deployment system. Instead of writing imperative scripts, you define a module that describes what to deploy. Ignition handles execution, error recovery, and state tracking.

  • Create the deployment module file at ignition/modules/NextNFT.ts (the ignition/modules/ folder should already exist in your project):
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

const NextNFTModule = buildModule("NextNFTModule", (m) => {
  const maxSupply = m.getParameter("maxSupply", 1000n);
  const mintPrice = m.getParameter("mintPrice", 10000000000000n); // 0.00001 ETH

  const nextNFT = m.contract("NextNFT", [maxSupply, mintPrice]);

  return { nextNFT };
});

export default NextNFTModule;

What does this module do?

The buildModule function creates a deployment plan. It declares a NextNFT contract to be deployed with a max supply of 1000 and a mint price of 0.00001 ETH (10000000000000 wei).

The m.getParameter() calls allow overriding these defaults later. The m.contract() call tells Ignition to deploy the compiled contract with those constructor arguments.

  • Save ignition/modules/NextNFT.ts.
  • Load your environment variables into your terminal session by running these commands (replace the placeholder values with your actual credentials from the .env file):
export SEPOLIA_RPC_URL=your-alchemy-sepolia-url-here
export PRIVATE_KEY=your-metamask-private-key-here

Why export instead of using the .env file directly?

The export command makes variables available to all commands in your current terminal session. The .env file is your record of these values, but the terminal needs them exported to pass them to Hardhat.

  • Deploy your contract to Sepolia by running this command:
npx hardhat ignition deploy ignition/modules/NextNFT.ts --network sepolia

What happens during deployment?

Hardhat Ignition compiles your contract, sends a deployment transaction to Sepolia using your private key, and waits for confirmation. The process may take 15-30 seconds while the transaction is mined on the network.

Deployment results are stored in ignition/deployments/ so Ignition can track the state and avoid duplicate deployments.

Once deployment completes, you will see output showing the deployed contract address. It looks something like this:

NextNFTModule#NextNFT - 0x1234...abcd
  • Copy your deployed contract address and save it somewhere safe. You will need it in the next step when building the frontend.
  • Verify the deployment by visiting Sepolia Etherscan and pasting your contract address in the search bar.

You should see your contract listed on Etherscan with its creation transaction.

Deployment failing?

  • If you see "insufficient funds", make sure your MetaMask wallet has Sepolia test ETH from the faucet you used in Step 1.
  • If you see "invalid account", double-check that PRIVATE_KEY is exported correctly without any extra spaces or quotes.
  • If you see a connection error, verify your SEPOLIA_RPC_URL is correct and your Alchemy app is configured for the Sepolia network.

Why is my Hardhat Ignition deployment to Sepolia failing?

✔️ Awesome, I've got everything!

Great. Double check you have saved both hardhat.config.ts and ignition/modules/NextNFT.ts.

ⓧ I'd like to double check the full code

Here is the full hardhat.config.ts:

import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox-viem";

const config: HardhatUserConfig = {
  solidity: "0.8.24",
  networks: {
    sepolia: {
      url: process.env.SEPOLIA_RPC_URL || "",
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
    },
  },
};

export default config;

Here is the full ignition/modules/NextNFT.ts:

import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";

const NextNFTModule = buildModule("NextNFTModule", (m) => {
  const maxSupply = m.getParameter("maxSupply", 1000n);
  const mintPrice = m.getParameter("mintPrice", 10000000000000n); // 0.00001 ETH

  const nextNFT = m.contract("NextNFT", [maxSupply, mintPrice]);

  return { nextNFT };
});

export default NextNFTModule;

Your contracts/NextNFT.sol should be unchanged from the previous step.

Your contract is live on Sepolia. Next up, you will build the Next.js frontend that connects to this contract and lets users mint NFTs through their browser.

Build the Next.js Frontend with Wallet Connection

Your smart contract is live on Sepolia. Now it's time to build the interface that lets users interact with it.

In this step, you'll create a Next.js frontend that connects to MetaMask using ethers.js v6. By the end, users will be able to connect their wallet and see their address displayed on the page.

In this step, get ready to:
  • Create a Next.js app inside a frontend directory.
  • Install ethers.js for blockchain interaction.
  • Build a wallet connection component that displays the connected address.
Create the Next.js app and install ethers.js

The frontend lives in its own directory inside your Hardhat project. This separation is standard in Web3 development.

  • In your terminal, make sure you are inside the nft-project folder (the Hardhat project root).
  • Scaffold a new Next.js app in a frontend directory by running this command:
npx create-next-app@latest frontend --src-dir --use-npm

What does this command do?

This creates a new Next.js project inside a folder called frontend. The --src-dir flag puts your source code inside a src/ directory for better organisation. The --use-npm flag ensures npm is used as the package manager.

The tool will ask whether to use the recommended Next.js defaults (TypeScript, ESLint, Tailwind CSS, App Router). Select Yes, use recommended defaults when prompted.

  • When prompted, select Yes, use recommended defaults to enable TypeScript, Tailwind CSS, ESLint, and the App Router.
  • Once the scaffolding finishes, move into the frontend directory by running:
cd frontend
  • Install ethers.js by running:
npm install ethers
  • Confirm the install succeeded by running:
ls node_modules/ethers

You should see a list of files inside the ethers folder, confirming the package is installed.

Seeing permission errors or network issues?

Make sure you are inside the frontend directory (not the Hardhat root). Run pwd to check your current location.

If npm hangs or fails, try deleting node_modules and running npm install again.

Help me fix npm install errors in my Next.js frontend directory.

Set up the contract configuration

Your frontend needs two things to talk to your smart contract: the contract address and its ABI (the list of functions it exposes). You'll store both in a single configuration file.

  • Create the lib directory and the contract config file by running:
mkdir -p src/lib && touch src/lib/contract.ts
  • Confirm the file exists by running:
ls src/lib

You should see contract.ts in the output.

  • Open src/lib/contract.ts in VS Code and paste the following code:
export const NEXT_NFT_ADDRESS = "your-contract-address-here";

export const NEXT_NFT_ABI = [
  "function mint(string memory uri) public payable",
  "function totalSupply() public view returns (uint256)",
  "function maxSupply() public view returns (uint256)",
  "function mintPrice() public view returns (uint256)",
  "function tokenURI(uint256 tokenId) public view returns (string memory)",
  "function ownerOf(uint256 tokenId) public view returns (address)",
];

What does this code do?

NEXT_NFT_ADDRESS holds the deployed contract's address on Sepolia. You'll replace the placeholder with the real address from your deployment output.

NEXT_NFT_ABI uses ethers.js v6's human-readable ABI format. Instead of importing a large JSON artifact, you define just the function signatures your frontend needs. This keeps the bundle small and readable.

  • Replace "your-contract-address-here" with the actual contract address from Step 3's deployment output.
  • Save src/lib/contract.ts.

Can't find your contract address?

Check the ignition/deployments folder in your Hardhat project root. The deployment artifacts contain the deployed address. You can also look at your terminal history from Step 3 for the deployment output.

Help me find my deployed contract address.

Build the wallet connection page

Now for the main event. You'll replace the default Next.js homepage with a component that connects to MetaMask and displays the connected wallet address.

  • Open src/app/page.tsx in VS Code. Delete all the existing content and paste the following code:
"use client";

import { useState, useEffect } from "react";
import { BrowserProvider, Contract, formatEther, parseEther } from "ethers";
import { NEXT_NFT_ABI, NEXT_NFT_ADDRESS } from "../lib/contract";

export default function Home() {
  const [account, setAccount] = useState<string | null>(null);
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [isMinting, setIsMinting] = useState(false);
  const [txHash, setTxHash] = useState<string | null>(null);
  const [totalSupply, setTotalSupply] = useState<number>(0);
  const [maxSupply, setMaxSupply] = useState<number>(0);

  async function connectWallet() {
    // Check if MetaMask is available in the browser
    if (typeof window.ethereum === "undefined") {
      alert("Please install MetaMask!");
      return;
    }

    // Create a provider and request account access
    const browserProvider = new BrowserProvider(window.ethereum);
    const accounts = await browserProvider.send("eth_requestAccounts", []);
    setProvider(browserProvider);
    setAccount(accounts[0]);
  }

  async function fetchSupplyInfo() {
    if (!provider) return;

    // Create a read-only contract instance to fetch supply data
    const contract = new Contract(NEXT_NFT_ADDRESS, NEXT_NFT_ABI, provider);
    const total = await contract.totalSupply();
    const max = await contract.maxSupply();
    setTotalSupply(Number(total));
    setMaxSupply(Number(max));
  }

  // Fetch supply info whenever the provider changes
  useEffect(() => {
    if (provider) {
      fetchSupplyInfo();
    }
  }, [provider]);

  return (
    <main className="min-h-screen flex flex-col items-center justify-center p-8 bg-gray-950 text-white">
      <h1 className="text-4xl font-bold mb-2">NextNFT</h1>
      <p className="text-gray-400 mb-8">Mint your unique NFT on Sepolia</p>

      {!account ? (
        <button
          onClick={connectWallet}
          className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition"
        >
          Connect Wallet
        </button>
      ) : (
        <div className="flex flex-col items-center gap-4">
          <p className="text-sm text-gray-400">
            Connected: {account.slice(0, 6)}...{account.slice(-4)}
          </p>
          <p className="text-sm text-gray-400">
            Minted: {totalSupply} / {maxSupply}
          </p>
        </div>
      )}
    </main>
  );
}

What does this code do?

"use client" tells Next.js this component runs in the browser. Web3 wallets only exist in the browser, so server-side rendering would break the wallet connection.

connectWallet() creates a BrowserProvider from MetaMask's injected window.ethereum object. It then calls eth_requestAccounts to prompt the user to approve the connection.

fetchSupplyInfo() creates a read-only contract instance and fetches the current minted supply and max supply from the blockchain. The useEffect hook triggers this automatically after the wallet connects.

The JSX conditionally renders either a Connect Wallet button (if no account is connected) or the truncated wallet address with supply info (once connected).

  • Save src/app/page.tsx.
  • Start the development server by running:
npm run dev
  • Open http://localhost:3000 in your browser.

You should see a dark page with the title "NextNFT" and a blue Connect Wallet button in the centre.

  • Click Connect Wallet.
  • MetaMask will pop up asking you to approve the connection. Click Connect in MetaMask.

After connecting, the button disappears and you see your truncated wallet address and the minted supply count.

Don't see MetaMask pop up?

Make sure your MetaMask extension is unlocked and set to the Sepolia network. If you have multiple wallet extensions installed, MetaMask might not be the default provider.

Check your browser console for errors. If you see a message about window.ethereum being undefined, verify MetaMask is installed and enabled for this site.

Help me debug my wallet connection issue.

✔️ Awesome, I've got everything!

Great. Make sure you've saved both files and your dev server is running.

ⓧ I'd like to double check the full code

Here is the complete frontend/src/lib/contract.ts:

export const NEXT_NFT_ADDRESS = "your-contract-address-here";

export const NEXT_NFT_ABI = [
  "function mint(string memory uri) public payable",
  "function totalSupply() public view returns (uint256)",
  "function maxSupply() public view returns (uint256)",
  "function mintPrice() public view returns (uint256)",
  "function tokenURI(uint256 tokenId) public view returns (string memory)",
  "function ownerOf(uint256 tokenId) public view returns (address)",
];

And here is the complete frontend/src/app/page.tsx:

"use client";

import { useState, useEffect } from "react";
import { BrowserProvider, Contract, formatEther, parseEther } from "ethers";
import { NEXT_NFT_ABI, NEXT_NFT_ADDRESS } from "../lib/contract";

export default function Home() {
  const [account, setAccount] = useState<string | null>(null);
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [isMinting, setIsMinting] = useState(false);
  const [txHash, setTxHash] = useState<string | null>(null);
  const [totalSupply, setTotalSupply] = useState<number>(0);
  const [maxSupply, setMaxSupply] = useState<number>(0);

  async function connectWallet() {
    if (typeof window.ethereum === "undefined") {
      alert("Please install MetaMask!");
      return;
    }

    const browserProvider = new BrowserProvider(window.ethereum);
    const accounts = await browserProvider.send("eth_requestAccounts", []);
    setProvider(browserProvider);
    setAccount(accounts[0]);
  }

  async function fetchSupplyInfo() {
    if (!provider) return;

    const contract = new Contract(NEXT_NFT_ADDRESS, NEXT_NFT_ABI, provider);
    const total = await contract.totalSupply();
    const max = await contract.maxSupply();
    setTotalSupply(Number(total));
    setMaxSupply(Number(max));
  }

  useEffect(() => {
    if (provider) {
      fetchSupplyInfo();
    }
  }, [provider]);

  return (
    <main className="min-h-screen flex flex-col items-center justify-center p-8 bg-gray-950 text-white">
      <h1 className="text-4xl font-bold mb-2">NextNFT</h1>
      <p className="text-gray-400 mb-8">Mint your unique NFT on Sepolia</p>

      {!account ? (
        <button
          onClick={connectWallet}
          className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition"
        >
          Connect Wallet
        </button>
      ) : (
        <div className="flex flex-col items-center gap-4">
          <p className="text-sm text-gray-400">
            Connected: {account.slice(0, 6)}...{account.slice(-4)}
          </p>
          <p className="text-sm text-gray-400">
            Minted: {totalSupply} / {maxSupply}
          </p>
        </div>
      )}
    </main>
  );
}

Your frontend is connected to MetaMask and reading data from your deployed contract. Next up, you'll add the mint button that lets users actually create NFTs.

Add the Mint NFT Functionality

Your DApp can connect to MetaMask and read data from your smart contract. Reading data is free because it doesn't change anything on the blockchain.

Minting an NFT is different. It writes new data to the blockchain, which means it requires a signed transaction and costs gas. In this step, you'll write the function that sends a mint transaction through MetaMask and gives the user real-time feedback on what's happening.

In this step, get ready to:
  • Write a mint function that calls your smart contract through MetaMask.
  • Handle transaction states with user-friendly loading feedback.
  • Display a link to Etherscan after a successful mint.
Create the mintNFT function

Until now, your app has only used a Provider to read blockchain data. To write data (like minting an NFT), you need a Signer. A Signer represents an authenticated account that can sign and send transactions.

  • In VS Code, open frontend/src/app/page.tsx.
  • Find the fetchSupplyInfo function. Copy the following mintNFT function directly below it:
  async function mintNFT() {
    if (!provider || !account) return;

    setIsMinting(true);
    setTxHash(null);

    try {
      const signer = await provider.getSigner();
      const contract = new Contract(NEXT_NFT_ADDRESS, NEXT_NFT_ABI, signer);
      const mintPrice = await contract.mintPrice();

      const tx = await contract.mint(
        "ipfs://bafkreihlc4o4wq36gkm3x4zcw6gv3wnqvehkocb4xcbfnxl2sscyiisaq",
        { value: mintPrice }
      );

      setTxHash(tx.hash);
      await tx.wait();
      await fetchSupplyInfo();
    } catch (error: unknown) {
      const err = error as Error;
      console.error("Mint failed:", err.message);
      alert("Mint failed: " + err.message);
    } finally {
      setIsMinting(false);
    }
  }

What does this function do?

This function handles the entire lifecycle of a mint transaction:

  • provider.getSigner() upgrades from a read-only Provider to a Signer that can send transactions. In ethers.js v6, this returns a Promise that you must await.
  • new Contract(NEXT_NFT_ADDRESS, NEXT_NFT_ABI, signer) creates a contract instance connected to the Signer instead of the Provider. This lets you call state-changing functions like mint.
  • The IPFS URI is the metadata location for the NFT. This is a placeholder URI pointing to on-chain metadata.
  • { value: mintPrice } sends ETH along with the transaction to satisfy the contract's payment requirement.
  • tx.wait() pauses execution until the transaction is confirmed on-chain. After confirmation, fetchSupplyInfo() refreshes the supply counter.

Why a Signer instead of a Provider?

In ethers.js, a Provider gives read-only access to the blockchain. A Signer wraps a private key (managed by MetaMask) and can authorize state-changing transactions.

When you pass a Signer to a Contract, calling a payable function like mint() triggers MetaMask's confirmation popup so the user can approve the spend.

Add the mint button and Etherscan link

The mintNFT function exists but nothing in the UI calls it yet. You need a button that triggers the mint and a link that appears after a successful transaction.

  • In the same file, scroll down to the JSX return statement. Find the closing </p> tag for the "Minted:" line:
          <p className="text-sm text-gray-400">
            Minted: {totalSupply} / {maxSupply}
          </p>
  • Copy the following code directly below that </p> tag, inside the same <div>:
          <button
            onClick={mintNFT}
            disabled={isMinting}
            className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white font-semibold py-3 px-6 rounded-lg transition"
          >
            {isMinting ? "Minting..." : "Mint NFT"}
          </button>
          {txHash && (
            <a
              href={`https://sepolia.etherscan.io/tx/${txHash}`}
              target="_blank"
              rel="noopener noreferrer"
              className="text-blue-400 hover:text-blue-300 text-sm underline"
            >
              View on Etherscan
            </a>
          )}

What does this code do?

  • The button calls mintNFT on click and is disabled while isMinting is true. This prevents double-clicks from sending multiple transactions.
  • The button text switches between "Mint NFT" and "Minting..." based on the isMinting state, giving the user visual feedback that something is happening.
  • The <a> tag only renders when txHash has a value. It links to Sepolia Etherscan where the user can verify their transaction on-chain.
  • Save frontend/src/app/page.tsx.

Don't see the button after saving?

  • Make sure you pasted the code inside the <div> that wraps the connected state, directly below the "Minted:" paragraph.
  • Check your terminal for compile errors. A missing closing bracket or misplaced curly brace will prevent the page from rendering.

Help me fix my mint button not appearing

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete frontend/src/app/page.tsx file at this point:

"use client";

import { useState, useEffect } from "react";
import { BrowserProvider, Contract, formatEther, parseEther } from "ethers";
import { NEXT_NFT_ABI, NEXT_NFT_ADDRESS } from "../lib/contract";

export default function Home() {
  const [account, setAccount] = useState<string | null>(null);
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [isMinting, setIsMinting] = useState(false);
  const [txHash, setTxHash] = useState<string | null>(null);
  const [totalSupply, setTotalSupply] = useState<number>(0);
  const [maxSupply, setMaxSupply] = useState<number>(0);

  async function connectWallet() {
    if (typeof window.ethereum === "undefined") {
      alert("Please install MetaMask!");
      return;
    }

    const browserProvider = new BrowserProvider(window.ethereum);
    const accounts = await browserProvider.send("eth_requestAccounts", []);
    setProvider(browserProvider);
    setAccount(accounts[0]);
  }

  async function fetchSupplyInfo() {
    if (!provider) return;

    const contract = new Contract(NEXT_NFT_ADDRESS, NEXT_NFT_ABI, provider);
    const total = await contract.totalSupply();
    const max = await contract.maxSupply();
    setTotalSupply(Number(total));
    setMaxSupply(Number(max));
  }

  async function mintNFT() {
    if (!provider || !account) return;

    setIsMinting(true);
    setTxHash(null);

    try {
      const signer = await provider.getSigner();
      const contract = new Contract(NEXT_NFT_ADDRESS, NEXT_NFT_ABI, signer);
      const mintPrice = await contract.mintPrice();

      const tx = await contract.mint(
        "ipfs://bafkreihlc4o4wq36gkm3x4zcw6gv3wnqvehkocb4xcbfnxl2sscyiisaq",
        { value: mintPrice }
      );

      setTxHash(tx.hash);
      await tx.wait();
      await fetchSupplyInfo();
    } catch (error: unknown) {
      const err = error as Error;
      console.error("Mint failed:", err.message);
      alert("Mint failed: " + err.message);
    } finally {
      setIsMinting(false);
    }
  }

  useEffect(() => {
    if (provider) {
      fetchSupplyInfo();
    }
  }, [provider]);

  return (
    <main className="min-h-screen flex flex-col items-center justify-center p-8 bg-gray-950 text-white">
      <h1 className="text-4xl font-bold mb-2">NextNFT</h1>
      <p className="text-gray-400 mb-8">Mint your unique NFT on Sepolia</p>

      {!account ? (
        <button
          onClick={connectWallet}
          className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition"
        >
          Connect Wallet
        </button>
      ) : (
        <div className="flex flex-col items-center gap-4">
          <p className="text-sm text-gray-400">
            Connected: {account.slice(0, 6)}...{account.slice(-4)}
          </p>
          <p className="text-sm text-gray-400">
            Minted: {totalSupply} / {maxSupply}
          </p>
          <button
            onClick={mintNFT}
            disabled={isMinting}
            className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white font-semibold py-3 px-6 rounded-lg transition"
          >
            {isMinting ? "Minting..." : "Mint NFT"}
          </button>
          {txHash && (
            <a
              href={`https://sepolia.etherscan.io/tx/${txHash}`}
              target="_blank"
              rel="noopener noreferrer"
              className="text-blue-400 hover:text-blue-300 text-sm underline"
            >
              View on Etherscan
            </a>
          )}
        </div>
      )}
    </main>
  );
}
Test minting an NFT

Time to see the full mint flow in action. When you click the mint button, MetaMask will pop up asking you to confirm the transaction.

  • Make sure your Next.js dev server is still running in the frontend directory. If it stopped, start it by running:
npm run dev
  • Open http://localhost:3000 in your browser.
  • Click Connect Wallet.
  • Approve the connection in MetaMask when the popup appears.
  • Make sure MetaMask is set to the Sepolia network.
  • Click the purple Mint NFT button.
  • MetaMask will display a transaction confirmation popup showing the gas fee and the value being sent.
  • Click Confirm in MetaMask to approve the transaction.
  • Watch the button text change to "Minting..." while the transaction processes.

After the transaction confirms (usually 15-30 seconds on Sepolia), a blue View on Etherscan link appears below the button. Click it to verify your transaction on the blockchain.

Mint transaction failing?

  • Make sure MetaMask is connected to the Sepolia network. Transactions will fail if you're on a different network.
  • Check that you have enough Sepolia test ETH in your wallet. The mint costs 0.00001 ETH plus gas fees.
  • Verify that the contract address in frontend/src/lib/contract.ts matches your deployed contract from the previous step.
  • If you see a "user rejected transaction" error, you clicked Reject in MetaMask instead of Confirm. Try again and approve the popup.

Help me debug my mint transaction

Your DApp can now mint NFTs on Sepolia. Next up, you'll build a gallery page that displays all the NFTs that have been minted from your contract.

Build the NFT Gallery Page

Your mint page works. Users can connect their wallet, pay the mint price, and receive an NFT. But right now, there's no way to see what's been minted.

In this step, you'll build a gallery page that reads directly from the blockchain to display every minted NFT. This is pure on-chain data reading. No database, no API server, just your frontend talking to the smart contract.

In this step, get ready to:
  • Create a gallery page that queries the contract for all minted token IDs.
  • Fetch each token's metadata URI and owner address from the blockchain.
  • Display minted NFTs in a responsive grid with token details.
Create the gallery page

The gallery needs to read on-chain data without sending any transactions. It calls totalSupply() to find how many NFTs exist, then loops through each token ID calling tokenURI() and ownerOf(). These are free read-only calls that cost zero gas.

  • In VS Code's Explorer sidebar, navigate to frontend/src/app/.
  • Create a new folder called gallery inside the app folder.
  • Inside the gallery folder, create a new file called page.tsx.
  • Paste the following code into frontend/src/app/gallery/page.tsx:
"use client";

import { useState, useEffect } from "react";
import { BrowserProvider, Contract } from "ethers";
import { NEXT_NFT_ABI, NEXT_NFT_ADDRESS } from "../../lib/contract";

// Define the shape of each NFT's data
interface NFTItem {
  tokenId: number;
  uri: string;
  owner: string;
}

export default function Gallery() {
  // Track the list of fetched NFTs and loading state
  const [nfts, setNfts] = useState<NFTItem[]>([]);
  const [loading, setLoading] = useState(true);

  async function fetchNFTs() {
    // Exit early if no wallet is available
    if (typeof window.ethereum === "undefined") {
      setLoading(false);
      return;
    }

    // Connect to the contract in read-only mode
    const provider = new BrowserProvider(window.ethereum);
    const contract = new Contract(NEXT_NFT_ADDRESS, NEXT_NFT_ABI, provider);

    // Get the total number of minted tokens
    const totalSupply = Number(await contract.totalSupply());
    const items: NFTItem[] = [];

    // Loop through each token ID and fetch its metadata
    for (let i = 0; i < totalSupply; i++) {
      const uri = await contract.tokenURI(i);
      const owner = await contract.ownerOf(i);
      items.push({ tokenId: i, uri, owner });
    }

    setNfts(items);
    setLoading(false);
  }

  // Fetch NFTs on page load
  useEffect(() => {
    fetchNFTs();
  }, []);

  // Show loading state while fetching
  if (loading) {
    return (
      <main className="min-h-screen flex items-center justify-center bg-gray-950 text-white">
        <p>Loading NFTs...</p>
      </main>
    );
  }

  return (
    <main className="min-h-screen p-8 bg-gray-950 text-white">
      <h1 className="text-3xl font-bold mb-8 text-center">NFT Gallery</h1>
      {nfts.length === 0 ? (
        <p className="text-center text-gray-400">No NFTs minted yet.</p>
      ) : (
        <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 max-w-6xl mx-auto">
          {nfts.map((nft) => (
            <div
              key={nft.tokenId}
              className="bg-gray-800 rounded-lg p-4 border border-gray-700"
            >
              <div className="w-full h-48 bg-gray-700 rounded mb-3 flex items-center justify-center">
                <span className="text-2xl font-bold text-gray-500">
                  #{nft.tokenId}
                </span>
              </div>
              <p className="text-sm font-semibold">Token #{nft.tokenId}</p>
              <p className="text-xs text-gray-400 truncate">
                Owner: {nft.owner}
              </p>
            </div>
          ))}
        </div>
      )}
    </main>
  );
}

What does this code do?

This page reads all minted NFTs directly from the blockchain. Here's how each section works:

  • NFTItem defines the shape of each NFT object: its token ID, metadata URI, and owner address.
  • fetchNFTs() calls totalSupply() to learn how many tokens exist, then iterates through each ID calling tokenURI() and ownerOf() to get each token's details.
  • The responsive grid uses Tailwind CSS breakpoints to show 1 column on mobile, 2 on small screens, 3 on medium, and 4 on large displays.
  • Each card displays a placeholder with the token number, the token ID label, and a truncated owner address.

Why no signer for reading data?

Notice this page only uses a provider, not a signer. Reading from the blockchain is free and doesn't require a wallet signature. You only need a signer when you send a transaction that changes state (like minting).

  • Save frontend/src/app/gallery/page.tsx.

Seeing a module not found error?

  • Check that the import path is ../../lib/contract (two levels up). The gallery page is nested one folder deeper than page.tsx on the home page, so it needs an extra ../.
  • Confirm the folder structure is frontend/src/app/gallery/page.tsx. The folder must be named gallery (lowercase) and the file must be page.tsx exactly.
  • help me fix a module not found error in my gallery page

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here's the complete frontend/src/app/gallery/page.tsx file:

"use client";

import { useState, useEffect } from "react";
import { BrowserProvider, Contract } from "ethers";
import { NEXT_NFT_ABI, NEXT_NFT_ADDRESS } from "../../lib/contract";

interface NFTItem {
  tokenId: number;
  uri: string;
  owner: string;
}

export default function Gallery() {
  const [nfts, setNfts] = useState<NFTItem[]>([]);
  const [loading, setLoading] = useState(true);

  async function fetchNFTs() {
    if (typeof window.ethereum === "undefined") {
      setLoading(false);
      return;
    }

    const provider = new BrowserProvider(window.ethereum);
    const contract = new Contract(NEXT_NFT_ADDRESS, NEXT_NFT_ABI, provider);

    const totalSupply = Number(await contract.totalSupply());
    const items: NFTItem[] = [];

    for (let i = 0; i < totalSupply; i++) {
      const uri = await contract.tokenURI(i);
      const owner = await contract.ownerOf(i);
      items.push({ tokenId: i, uri, owner });
    }

    setNfts(items);
    setLoading(false);
  }

  useEffect(() => {
    fetchNFTs();
  }, []);

  if (loading) {
    return (
      <main className="min-h-screen flex items-center justify-center bg-gray-950 text-white">
        <p>Loading NFTs...</p>
      </main>
    );
  }

  return (
    <main className="min-h-screen p-8 bg-gray-950 text-white">
      <h1 className="text-3xl font-bold mb-8 text-center">NFT Gallery</h1>
      {nfts.length === 0 ? (
        <p className="text-center text-gray-400">No NFTs minted yet.</p>
      ) : (
        <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 max-w-6xl mx-auto">
          {nfts.map((nft) => (
            <div
              key={nft.tokenId}
              className="bg-gray-800 rounded-lg p-4 border border-gray-700"
            >
              <div className="w-full h-48 bg-gray-700 rounded mb-3 flex items-center justify-center">
                <span className="text-2xl font-bold text-gray-500">
                  #{nft.tokenId}
                </span>
              </div>
              <p className="text-sm font-semibold">Token #{nft.tokenId}</p>
              <p className="text-xs text-gray-400 truncate">
                Owner: {nft.owner}
              </p>
            </div>
          ))}
        </div>
      )}
    </main>
  );
}
Test the gallery in your browser

With the Next.js App Router, creating a folder called gallery with a page.tsx inside it automatically creates a route at /gallery. No router configuration needed.

  • Make sure your dev server is still running in the terminal. If it stopped, start it again by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000/gallery.
  • Make sure MetaMask is connected to the Sepolia network.

If you minted an NFT in the previous step, you should see it appear as a card in the grid with its token ID and your wallet address as the owner. If no NFTs have been minted yet, you'll see the message "No NFTs minted yet."

Mint and refresh to see it live

Go back to http://localhost:3000, mint an NFT, then return to /gallery and refresh the page. Your newly minted token appears in the grid.

Gallery shows "No NFTs minted yet" even though you minted?

  • Check that MetaMask is set to the Sepolia network. The gallery reads from whatever network MetaMask is connected to.
  • Verify the contract address in frontend/src/lib/contract.ts matches the address you deployed to in Step 3.
  • Try a hard refresh (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to clear the browser cache.
  • help me debug why my NFT gallery is not showing minted tokens

Your full-stack NFT DApp is complete. You have a smart contract on Sepolia, a mint page that handles wallet connection and transactions, and a gallery that reads on-chain data. Next up, the Secret Mission challenges you to add a quantity selector and a live supply progress bar.

Secret mission

Mint Quantity Selector and Supply Progress

Your DApp works, but real NFT minting sites let users choose how many to mint and show how close the collection is to selling out. Can you add a quantity selector and a live progress bar that updates after each mint?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Sepolia testnet and runs locally, so there are no ongoing costs.

Resources you used:

  • Local project folder (Hardhat project and frontend/ directory).
  • MetaMask browser extension with Sepolia test ETH.

Keep everything running

No action needed. Choose this if you want to keep the project as a portfolio piece or plan to build on it further.

  • Your project files remain on your Desktop.
  • Your deployed NextNFT contract stays on Sepolia permanently at no cost.
  • MetaMask stays installed in your browser for future Web3 projects.
  • Your RPC provider account (Alchemy or Infura) remains active on the free tier at no cost.
  • Node.js remains installed on your machine for other JavaScript projects.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and accounts stay intact for when you return.

  • Stop the Next.js development server by pressing Ctrl+C in the terminal where it is running.
  • Close any open VS Code windows for this project.
  • Your project files, MetaMask wallet, deployed contract, and RPC provider account all remain ready for when you come back.

Delete - I don't want to use this again

Remove all local project resources and clean up your browser. Your deployed contract cannot be removed from the blockchain, but it costs nothing on Sepolia.

  • Delete your local project folder by running this command from your Desktop:

macOS

cd ~/Desktop
rm -rf [[PROJECT_FOLDER="your project folder name"]]

Windows

cd ~\Desktop
rmdir /s /q [[PROJECT_FOLDER="your project folder name"]]

Find your folder name

If you are not sure what you named your project folder, run ls ~/Desktop (macOS) or dir ~\Desktop (Windows) to list your Desktop contents and find it.

  • Remove MetaMask from your browser. Open your browser's extensions management page, find MetaMask in the list, and select the option to remove it.

What about the deployed contract?

Smart contracts on the blockchain are immutable. Your NextNFT contract will remain on Sepolia permanently, but this has no cost impact.

Sepolia is a test network with no real monetary value. The test ETH you used is not real currency.

Nice Work!

Nice Work!

Nice work! You've just built a complete NFT minting DApp from smart contract to frontend, deployed on Sepolia testnet.

This is the foundational full-stack Web3 project that agencies and NFT startups look for when hiring freelancers.

You've learned how to:

  • Write and deploy an ERC-721 smart contract with OpenZeppelin using Hardhat Ignition on Sepolia testnet.
  • Connect a Next.js frontend to MetaMask using ethers.js v6 BrowserProvider and handle transaction states with user-friendly feedback.
  • Build an NFT gallery that reads on-chain data by querying total supply, token URIs, and owner addresses from the deployed contract.
  • Added a quantity selector and live supply progress bar that auto-refreshes after each mint.

Ready to quiz yourself?