A very simple mint website for your NFT project

a minor extracting cool gems with a pickaxe in an underground cave, painted in pastel
Image generated by DALL·E 2

In a previous article, we saw how to create art for an NFT collection, thanks to a tool called Bueno. Then, we saw how to write and deploy a smart contract. There's still a critical step missing though: if we want people to be able to mint our NFTs, we need a mint website. This is what this article is about.

A mint website is simply a website that will directly interact with your smart contract, so your visitors can do multiple things with it, the most important being to be able to mint your NFT.

To create that website, we'll use a fantastic library called RainbowKit.

Over the past few months, I've become a big fan of everything Rainbow produces. In addition to providing the best mobile wallet, very interesting web3 resources, and a nice webpage to visualize your NFTs, they also do a lot for developers like me, by providing some very useful open-source tools. Among them, RainbowKit has become by far my favorite.

RainbowKit describes itself as the best way to connect a wallet, and it's not marketing bullshit at all, it's the truth. A few months back, I had to implement a button to connect a wallet on a website and it was painful, to say the least. First I had to check that the user had MetaMask installed and provide a fallback if that was not the case, then do all the checks to see if we were on the Ethereum Mainnet (and not on a test network like Ropsten or Rinkeby), then make requests to get the wallet info and do some other stuff. It's basically what is described in this tutorial and it doesn't look terrible at first sight but this is only if you want to support MetaMask. If you want to provide more options like WalletConnect or Coinbase Wallet, you have to do separate implementations. Those dark days are now behind us, thanks to RainbowKit.

RainbowKit is built on top of ethers.js, a JavaScript library for interacting with the Ethereum blockchain, and wagmi, which provides React Hooks to achieve the same purpose but abstracts things a lot for us.

The first thing to do is to initialize a new RainbowKit project, which is the easiest thing in the world:

$ npm init @rainbow-me/rainbowkit@latest

If you prefer yarn or pnpm, you can also use one of these options:

$ yarn create @rainbow-me/rainbowkit@latest
# or
$ pnpm create @rainbow-me/rainbowkit@latest

Now let's go inside the folder we just created and run:

$ npm run dev

A RainbowKit project is actually a Next.js project where the RainbowKit library has been incorporated directly to save us time.

We should now be able to open the project on http://localhost:3000 and this is what we'll see:

As you can see, we have a "Connect Wallet" button present on top of the page, and clicking on it will display multiple options to connect.

If I choose MetaMask, after allowing http://localhost:3000 to see things from my wallet, it will automatically connect me and show to which blockchain I'm connected, my ETH balance, and my user information.

You'll notice that RainbowKit detects my ENS name from my wallet address (vinchbat.eth in my case) and also the profile picture I set in my ENS text records. If you don't have any of this, it will of course fall back to your wallet address and a default avatar.

By clicking on the first dropdown, we can switch networks, and by clicking on the second one, we can copy the wallet address and disconnect.

OK, that's a great start and we could actually stop here if all we wanted to do was just to be able to connect our wallet, but we want to go a bit further.

But before editing what's inside pages/index.tsx it's suggested, let's have a quick look at the _app.tsx file that was automatically generated for us:

import "styles/globals.css";
import "@rainbow-me/rainbowkit/styles.css";
import type { AppProps } from "next/app";
import { RainbowKitProvider, getDefaultWallets } from "@rainbow-me/rainbowkit";
import { chain, configureChains, createClient, WagmiConfig } from "wagmi";
import { alchemyProvider } from "wagmi/providers/alchemy";
import { publicProvider } from "wagmi/providers/public";

const { chains, provider, webSocketProvider } = configureChains(
  [
    chain.mainnet,
    chain.polygon,
    chain.optimism,
    chain.arbitrum,
    ...(process.env.NEXT_PUBLIC_ENABLE_TESTNETS === "true"
      ? [chain.goerli, chain.kovan, chain.rinkeby, chain.ropsten]
      : []),
  ],
  [
    alchemyProvider({
      apiKey: process.env.ALCHEMY_API_KEY,
    }),
    publicProvider(),
  ]
);

const { connectors } = getDefaultWallets({
  appName: "RainbowKit App",
  chains,
});

const wagmiClient = createClient({
  autoConnect: true,
  connectors,
  provider,
  webSocketProvider,
});

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <WagmiConfig client={wagmiClient}>
      <RainbowKitProvider chains={chains}>
        <Component {...pageProps} />
      </RainbowKitProvider>
    </WagmiConfig>
  );
}

export default MyApp;

I'm not going to go into too much detail here because the RainbowKit documentation will explain it to you better than I and that's not the purpose of this article anyway, but I thought it was relevant that you know that all these configurations exist even if we don't worry too much about them in this case since it was made automatically for us. However, if you want to add RainbowKit to an existing project, you'll have to do all these things all by yourself.

Back to pages/index.tsx, if we remove everything to just keep the button, this is what we get:

import { ConnectButton } from "@rainbow-me/rainbowkit";
import type { NextPage } from "next";
import Head from "next/head";
import styles from "../styles/Home.module.css";

const Home: NextPage = () => {
  return (
    <div className={styles.container}>
      <Head>
        <title>RainbowKit App</title>
        <meta
          name="description"
          content="Generated by @rainbow-me/create-rainbowkit"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <ConnectButton />
      </main>
    </div>
  );
};

export default Home;

All the magic happens inside <main />, with the inclusion of the <ConnectButton /> component. The rest is just related to Next.js.

If we want to get rid of things we don't need, it's totally possible. For example, if we don't care about the switching networks feature and we don't want to show the ETH balance, we can simply do this:

<ConnectButton showBalance={false} chainStatus="none" />

You can see all the possible options for <ConnectButton /> in the RainbowKit documentation (there are plenty).

And here's the result:

It's much cleaner but even though the UI Rainbow provides out of the box is really nice, we want to choose how the thing looks, so it better fits with our design system (let's pretend we have a design system).

How can we do that? Well, we can just use the <ConnectButton.Custom /> component this way:

<ConnectButton.Custom>
  {({ openConnectModal }) => {
    return (
      <Button
        onClick={openConnectModal}
      >
        Connect Wallet
      </Button>
    );
  }}
</ConnectButton.Custom>

In this case, we use the <Button /> from our design system so it will look like the rest of our UI. Obviously, this example is a bit too simplistic because we also need to take care of the case where we're connected and want to display the username and/or avatar.

If you want to go a bit further with the custom behavior, have another look at the RainbowKit documentation, it's perfectly explained there!

For my example, I actually don't really care about having a design system, I'm fine with the default RainbowKit UI. After some styling (I'm not a designer, please don't be mean), this is what I came up with:

And when I'm connected:

And here's the complete code for this:

import { ConnectButton } from "@rainbow-me/rainbowkit";
import type { NextPage } from "next";
import Image from "next/image";

import styles from "./index.module.css";

const Home: NextPage = () => {
  return (
    <>
      <div className={styles.container}>
        <div className={styles.main}>
          <div className={styles.logoContainer}>
            <Image src="/img/logo.svg" alt="Stupid Faces logo" layout="fill" />
          </div>
          <ConnectButton showBalance={false} chainStatus="none" />
        </div>
      </div>
    </>
  );
};

export default Home;

Now, what we want to do is show some minting options when connected. Ideally, we would like to have a slider where we can choose the number of NFTs we want to mint, an overview of how much we'll have to pay, and a mint button.

But first, let's see how we can detect that we are connected...

import { useEffect, useState } from "react";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import type { NextPage } from "next";
import Image from "next/image";
import { useAccount } from "wagmi";

import styles from "./index.module.css";

const Home: NextPage = () => {
  const [isConnected, setIsConnected] = useState(false);

  const { address } = useAccount();

  useEffect(() => {
    setIsConnected(!!address);
  }, [address]);

  return (
    <>
      <div className={styles.container}>
        <div className={styles.main}>
          <div className={styles.logoContainer}>
            <Image src="/img/logo.svg" alt="Stupid Faces logo" layout="fill" />
          </div>
          <ConnectButton showBalance={false} chainStatus="none" />
          {isConnected && <div>HELLO {address}</div>}
        </div>
      </div>
    </>
  );
};

export default Home;

The main change is here:

const [isConnected, setIsConnected] = useState(false);

const { address } = useAccount();

useEffect(() => {
  setIsConnected(!!address);
}, [address]);

We use the useAccount hook from wagmi that gives us the connected address. It's then easy to have a isConnected boolean with some useState and useEffect magic.

Then in the JSX part, we just show a message if we detect that the user is connected.

{isConnected && <div>HELLO {address}</div>}

Now let's create our slider and our button...

import { useEffect, useState } from "react";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import type { NextPage } from "next";
import Image from "next/image";
import { useAccount } from "wagmi";
import Button from "@mui/material/Button";
import Slider from "@mui/material/Slider";

import styles from "./index.module.css";

const PRICE = 0.001;

const Home: NextPage = () => {
  const [isConnected, setIsConnected] = useState(false);
  const [quantity, setQuantity] = useState<number>(3);

  const { address } = useAccount();

  const handleChange = (event: Event, newValue: number | number[]) => {
    setQuantity(newValue as number);
  };

  useEffect(() => {
    setIsConnected(!!address);
  }, [address]);

  return (
    <>
      <div className={styles.container}>
        <div className={styles.main}>
          <div className={styles.logoContainer}>
            <Image src="/img/logo.svg" alt="Stupid Faces logo" layout="fill" />
          </div>
          <ConnectButton showBalance={false} chainStatus="none" />
          {isConnected && (
            <>
              <div className={styles.price}>
                You are about to mint <strong>{quantity}</strong> Stupid Faces
                NFT{quantity > 1 && "s"} for a total of{" "}
                <strong>
                  {Math.round(quantity * PRICE * 1000) / 1000} ETH
                </strong>
                . Move the slider below to adjust the quantity.
              </div>
              <Slider
                color="secondary"
                value={quantity}
                onChange={handleChange}
                aria-label="Quantity"
                valueLabelDisplay="auto"
                step={1}
                min={1}
                max={10}
              />
              <Button variant="contained" color="secondary" size="large">
                Mint
              </Button>
            </>
          )}
        </div>
      </div>
    </>
  );
};

export default Home;

A few new things here...

  • We import and use Button and Slider from @mui/material.
  • We put the PRICE in a constant. Obviously, this needs to be the exact same price we set in our contract for a single token.
  • We have a new quantity state and a handleChange method that are useful for the slider.
  • We display a message to show how many tokens we're about to mint and at which price (the Math.round thing is useful to make sure we don't have more than 3 decimals because JavaScript is sometimes very weird with floating point numbers).

At the moment, clicking on the button does nothing, but it will get interesting from now on...

import { useEffect, useState } from "react";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import type { NextPage } from "next";
import Image from "next/image";
import { ethers } from "ethers";
import { useAccount, usePrepareContractWrite, useContractWrite } from "wagmi";
import Button from "@mui/material/Button";
import Slider from "@mui/material/Slider";

import styles from "./index.module.css";
import contractInterface from "abi/contract-abi.json";

const PRICE = 0.001;

const Home: NextPage = () => {
  const [isConnected, setIsConnected] = useState(false);
  const [quantity, setQuantity] = useState<number>(3);

  const { address } = useAccount();

  const { config } = usePrepareContractWrite({
    addressOrName: "0x9E33B16db8F972E57b9cB0da9438d840A5C9f7A0",
    contractInterface: contractInterface,
    functionName: "mint",
    args: [quantity],
    overrides: {
      from: address,
      value: ethers.utils.parseEther((quantity * PRICE).toString()),
    },
  });

  const { write } = useContractWrite(config);

  const handleChange = (event: Event, newValue: number | number[]) => {
    setQuantity(newValue as number);
  };

  useEffect(() => {
    setIsConnected(!!address);
  }, [address]);

  return (
    <>
      <div className={styles.container}>
        <div className={styles.main}>
          <div className={styles.logoContainer}>
            <Image src="/img/logo.svg" alt="Stupid Faces logo" layout="fill" />
          </div>
          <ConnectButton showBalance={false} chainStatus="none" />
          {isConnected && (
            <>
              <div className={styles.price}>
                You are about to mint <strong>{quantity}</strong> Stupid Faces
                NFT{quantity > 1 && "s"} for a total of{" "}
                <strong>
                  {Math.round(quantity * PRICE * 1000) / 1000} ETH
                </strong>
                . Move the slider below to adjust the quantity.
              </div>
              <Slider
                color="secondary"
                value={quantity}
                onChange={handleChange}
                aria-label="Quantity"
                valueLabelDisplay="auto"
                step={1}
                min={1}
                max={10}
              />
              <Button
                variant="contained"
                color="secondary"
                size="large"
                onClick={() => {
                  write?.();
                }}
              >
                Mint
              </Button>
            </>
          )}
        </div>
      </div>
    </>
  );
};

export default Home;

We use two hooks for wagmi: usePrepareContractWrite and useContractWrite. As their name indicates, the first is used to prepare the writing on the contract and the other is used for the writing as such.

usePrepareContractWrite requires a few parameters:

  • addressOrName is the address of the smart contract.
  • contractInterface is the ABI of the contract. The ABI is a descriptive interface of the contract, so we know how we can interact with it. There are many ways to get a contract ABI but the simplest is probably to get it from Etherscan. On the Etherscan contract page, go to the "Contract" tab, then "Code" and scroll until you find the "Contract ABI" section. On the right, click on "Export ABI" and choose "Raw/Text format". Save the content of the file to a JSON file that you import in your React code, the way we did in the code above.
  • functionName is the method of the smart contract we want to call.
  • args are the arguments of the method. In this case, we only need quantity.
  • overrides is useful to pass extra information to the contract. In this case, we need to pass the wallet address and the money to send to the mint function because it's a payable one.

Then, we just have to pass the config we got from usePrepareContractWrite to useContractWrite and it will give us a write function in return that you can call when you click on the mint button:

<Button
  variant="contained"
  color="secondary"
  size="large"
  onClick={() => {
    write?.();
  }}
>
  Mint
</Button>

And you know what? That's it!

People can now mint tokens from our NFT collection. When they click on the mint button, it will open MetaMask (or another provider they chose) and they will be asked to confirm the transaction. After a moment, they will get our NFT in their wallet.

Well, that's not really it though. We want to add some visual feedback so the user knows what's going on because interacting with a smart contract on the blockchain can be a bit slow.

Here's the complete code:

import { useEffect, useState } from "react";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import type { NextPage } from "next";
import Image from "next/image";
import { ethers } from "ethers";
import {
  useAccount,
  usePrepareContractWrite,
  useContractWrite,
  useWaitForTransaction,
} from "wagmi";
import Button from "@mui/material/Button";
import Slider from "@mui/material/Slider";

import styles from "./index.module.css";
import contractInterface from "abi/contract-abi.json";
import { success } from "helpers/effects";

const PRICE = 0.001;

const Home: NextPage = () => {
  const [isConnected, setIsConnected] = useState(false);
  const [quantity, setQuantity] = useState<number>(3);

  const { address } = useAccount();

  const { config, error: contractError } = usePrepareContractWrite({
    addressOrName: "0x9E33B16db8F972E57b9cB0da9438d840A5C9f7A0",
    contractInterface: contractInterface,
    functionName: "mint",
    args: [quantity],
    overrides: {
      from: address,
      value: ethers.utils.parseEther((quantity * PRICE).toString()),
    },
  });

  const {
    isLoading,
    isSuccess: isStarted,
    error: mintError,
    data: mintData,
    write,
  } = useContractWrite(config);

  const { isSuccess: isMinted } = useWaitForTransaction({
    hash: mintData?.hash,
  });

  const handleChange = (event: Event, newValue: number | number[]) => {
    setQuantity(newValue as number);
  };

  useEffect(() => {
    setIsConnected(!!address);
  }, [address]);

  useEffect(() => {
    if (isMinted) {
      success();
    }
  }, [isMinted]);

  return (
    <>
      <div className={styles.container}>
        <div className={styles.main}>
          <div className={styles.logoContainer}>
            <Image src="/img/logo.svg" alt="Stupid Faces logo" layout="fill" />
          </div>
          <ConnectButton showBalance={false} chainStatus="none" />
          {isConnected && (
            <>
              {isMinted ? (
                <>
                  <div className={styles.status}>Success!</div>
                  <div className={styles.action}>
                    <a
                      href={`https://opensea.io/${address}?tab=collected`}
                      target="_blank"
                      rel="noreferrer"
                    >
                      View on OpenSea
                    </a>
                  </div>
                </>
              ) : (
                <>
                  <div className={styles.price}>
                    You are about to mint <strong>{quantity}</strong> Stupid
                    Faces NFT{quantity > 1 && "s"} for a total of{" "}
                    <strong>
                      {Math.round(quantity * PRICE * 1000) / 1000} ETH
                    </strong>
                    . Move the slider below to adjust the quantity.
                  </div>
                  <Slider
                    color="secondary"
                    value={quantity}
                    onChange={handleChange}
                    aria-label="Quantity"
                    valueLabelDisplay="auto"
                    step={1}
                    min={1}
                    max={10}
                    disabled={isLoading || isStarted}
                  />
                  <Button
                    variant="contained"
                    color="secondary"
                    size="large"
                    onClick={() => {
                      write?.();
                    }}
                    disabled={!!contractError || isLoading || isStarted}
                  >
                    Mint
                  </Button>
                  {isLoading && (
                    <div className={styles.status}>Waiting for approval...</div>
                  )}
                  {isStarted && <div className={styles.status}>Minting...</div>}
                  {mintData && (
                    <div className={styles.action}>
                      <a
                        href={`https://etherscan.io/tx/${mintData.hash}`}
                        target="_blank"
                        rel="noreferrer"
                      >
                        View transaction
                      </a>
                    </div>
                  )}
                  {contractError && (
                    <div className={styles.error}>
                      An error occurred while preparing the transaction. Make sure that you have enough funds and that you haven’t reached your limit of 10 tokens.
                    </div>
                  )}
                  {mintError && (
                    <div className={styles.error}>
                      An error occurred while accessing your wallet or processing the transaction.
                    </div>
                  )}
                </>
              )}
            </>
          )}
        </div>
      </div>
    </>
  );
};

export default Home;

The main goal of the new pieces of code we added is to know the current state of the transaction, so we can display relevant messages to the user. We obviously also want to handle errors correctly.

We basically need three different statuses:

  • isLoading happens right after we call the write function (when we click on the mint button) and it comes from useContractWrite.
  • isStarted indicates that we started the write, meaning that the user confirmed the transaction in MetaMask (or another provider they chose). That doesn't mean the transaction is completed though, just that it started. It comes from useContractWrite as isSuccess but we renamed it to isStarted for clarity.
  • isMinted indicates that the mint is completed. To have this information, we added the useWaitForTransaction hook from wagmi and gave it the transaction hash we got from useContractWrite to be able to get the info. It comes from useWaitForTransaction as isSuccess but we renamed it isMinted for clarity.

We used these statuses to display different messages to the user, and we do the same kind of things for all the errors that could be triggered during the entire process.

We also added a funny animation when the mint is completed (a firework of confetti):

useEffect(() => {
  if (isMinted) {
    success();
  }
}, [isMinted]);

Now, that's really it! 🎉

The code is hosted on GitHub if you want to have a closer look. And if you want to test the whole process, don't hesitate to mint a Stupid Faces NFT on https://stupidfaces.0x3.studio 😘