Blog home

How to Create an NFT Minting App on Base with Crossmint and Pinata

Steve

Published on

20 min read

How to Create an NFT Minting App on Base with Crossmint and Pinata

Build your NFT minting app using Next.js, Pinata, and Crossmint. Securely mint and host NFTs on IPFS with this comprehensive guide.

Web3 infrastructure has grown significantly in the past several years and interacting with blockchains has never been better for developers. There are many tools you can use to quickly and easily build your apps with, and a couple of those are Crossmint and Pinata. Just recently Crossmint announced support for Base, a new layer 2 blockchain by Coinbase, for their minting API. Combining this with Pinata for self governed IPFS is a breeze, so in this post we’ll show you how to make an NFT minting app for Base using Pinata and Crossmint. If you want a fully blown demo of what we’re building, check out Enchantmint.cloud!

Setup

To follow along with this technical tutorial you will need a few things:

  • Node.js version 16 or higher
  • NPM version 8 or higher
  • A text editor like VS Code
  • Accounts for:
  • Pinata
  • Crossmint
  • Walletconnect

We’ll walk you through the setup process for each of them.

Pinata

Getting your Pinata account setup is super simple, just head over to app.pinata.cloud/register and sign up for a free account! After signing up go ahead and make an API key with admin access, and save the Api Key, Api Secret, and JWT somewhere safe. We’ll only be using the JWT in this tutorial.

   

You will also want to grab your Dedicated Gateway domain which comes with your free account. Simply visit the Gateways tab and copy it down!

Crossmint

Crossmint is also simple to setup but will take just a few more steps. First you’ll want to head over to the Crossmint Console and sign up for an account. Something important to note is that Crossmint has separate consoles for staging and production, so if you want to make NFTs on a testnet then you will want to select staging here.

After you have signed in to your chosen console, click on the Collections tab and click “New Collection.”

Walk through the steps to give a name, description, and image for your collection. After that’s complete find the Project ID and Collection ID in the top left corner. Save both of these. If you don’t see Base available as a current chain you can also use the [API endpoint](https://docs.crossmint.com/reference/create-collection) to create a collection. Go back to the Console and click on the API Keys page. From this page create a new API key with all the Minting API scopes, thats all you should need. Once those are created you’ll see both a Project ID and your Client Secret ; save both of these as well and you should be set!

Walletconnect

This project will use Walletconnect for our wallet client so we can have users login and mint NFTs to their address. Just visit Walletconnect.com and sign up for a free account and click “New Project” in the top right, give it a name, then click “create.”

Then just copy the Project ID and save it with the rest of your secrets and keys. That’s it!

Optional: Alchemy

In this project we’ll just use the publicProvider for our wallet setup but if you wanted to use your own custom RPC you could get an Alchemy account and use it in the Wagmi config!

Building the App

To kick off our project we’re going to use Next.js, so pull up your terminal and run

npx create-next-app@latest

You can select which options you prefer such as typescript or tailwindcss, but our examples will go basic using the pages router. Once that’s complete and you cd into the project, install the following dependencies:

npm install @rainbow-me/rainbowkit wagmi uuid

Next we’ll open up our project in text editor of choice and create a file in the root of the project folder called .env.local where we will stash all our variables we created earlier. You can use this template for all the names and values.

// .env.local

PINATA_JWT=
CROSSMINT_CLIENT_SECRET=
CROSSMINT_PROJECT_ID=
CROSSMINT_COLLECTION_ID=
NEXT_PUBLIC_WALLETCONNECT_ID=
NEXT_PUBLIC_PINATA_DEDICATED_GATEWAY=

The Pinata Dedicated Gateway domain should be the following format: https://your-domain-name.mypinata.cloud/ipfs/.

With our secrets and keys ready to go, let’s open up the _app.js file to setup our Rainbow Kit wallet!

// _app.js

import { useEffect, useState } from "react"
import '@/styles/globals.css'
import '@rainbow-me/rainbowkit/styles.css';
import {
  getDefaultWallets,
  RainbowKitProvider,
} from '@rainbow-me/rainbowkit';
import { configureChains, createConfig, WagmiConfig } from 'wagmi';
import {
  base
} from 'wagmi/chains';
import { publicProvider } from 'wagmi/providers/public';



const { chains, publicClient } = configureChains(
  [base],
  
    publicProvider()
  ]
);

const { connectors } = getDefaultWallets({
  appName: 'My RainbowKit App',
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_ID,
  chains
});

const wagmiConfig = createConfig({
  autoConnect: true,
  connectors,
  publicClient
})


export default function App({ Component, pageProps }) {

  const [isReady, setIsReady] = useState(false)

  useEffect(() => {
    setIsReady(true)
  }, [])

  return (
    <>
      {isReady ? (
        <WagmiConfig config={wagmiConfig}>
          <RainbowKitProvider
            chains={chains}
            initialChain={base}
          >
            <Component {...pageProps} />
          </RainbowKitProvider>
        </WagmiConfig>
      ) : null}
    </>
  );
}

Here we’re doing a couple of things. First we import all the things we need for our wallet provider and declare some constants. Most of this is boiler plate that you can get from Rainbow Kit’s docs and templates for Next.js. We’re also doing a bit of a hacky solution to prevent hydration errors by using useEffect and useState.

One small thing we need to adjust for Rainbow Kit to work is the next.config.js file, where you can simply paste this from their Next.js template.

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  webpack: config => {
    config.resolve.fallback = { fs: false, net: false, tls: false };
    config.externals.push('pino-pretty', 'lokijs', 'encoding');
    return config;
  },
}

module.exports = nextConfig

Now let’s move to our index.js file and clear out all the boiler plate and reduce it to this

//index.js

import Head from 'next/head'

export default function Home() {

  return (
    <>
      <Head>
        <title>Pinata NFT Minter</title>
        <meta name='description' content='Generated by create next app' />
        <meta name='viewport' content='width=device-width, initial-scale=1' />
        <link rel='icon' href='/favicon.ico' />
      </Head>
      <main>
        <div>
          <h1>Pinata NFT Minter</h1>
        </div>
      </main>
    </>
  )
}

This page is gonna be really easy, all we’re going to do is import a ConnectButton from Rainbow Kit, and a useAccount hook from Wagmi. Then we’ll extract isConnected from useAccount to help us do some conditional rendering for our main component. This way we can greet the user with a wallet connect button, then once they connect, show them our minting Form component (which we’ll build soon but go ahead declare the import now).

// index.js

import Head from 'next/head'
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount } from 'wagmi'
import Form from "@/components/Form"

export default function Home() {

  const { isConnected } = useAccount()

  return (
    <>
      <Head>
        <title>Pinata NFT Minter</title>
        <meta name='description' content='Generated by create next app' />
        <meta name='viewport' content='width=device-width, initial-scale=1' />
        <link rel='icon' href='/favicon.ico' />
      </Head>
      <main>
        <div>
          <h1>Pinata NFT Minter</h1>
          {!isConnected && (
            {/* if they're not connected, show them the button to connect */}
            <ConnectButton />
          )}
          {isConnected && (
            {/* Declare our new Form component that we're about to build */}
           <Form />
          )}
        </div>
      </main>
    </>
  )
}

Our main sauce will be our form component which will let users enter the details of their NFT, upload a file, and ultimately mint it. To do this create a /components directory in your src folder, then add Form.js to it.  Open the file after creating it and add the following:

// Form.js

import { useState } from "react";
import { useAccount } from "wagmi";
import { ConnectButton } from "@rainbow-me/rainbowkit";

const Form = () => {
  const [selectedFile, setSelectedFile] = useState();
  const [name, setName] = useState();
  const [description, setDescription] = useState();
  const [externalURL, setExternalURL] = useState();
  const [osLink, setOsLink] = useState("https://opensea.io");
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState("");
  const [isComplete, setIsComplete] = useState(false);

  const { address } = useAccount();

  const fileChangeHandler = (event) => {
    setSelectedFile(event.target.files[0]);
  };
  const nameChangeHandler = (event) => {
    setName(event.target.value);
  };
  const descriptionChangeHandler = (event) => {
    setDescription(event.target.value);
  };
  const externalURLChangeHandler = (event) => {
    setExternalURL(event.target.value);
  };

  const handleSubmission = async () => {
    let key;
    let keyId;
    let fileCID;
    let uri;

    try {
      // Minting will go here
    } catch (error) {
      console.log(error);
    }
  };

  return (
    <div>
      <div>
        <ConnectButton />
      </div>
      {!isLoading && !isComplete && (
        <>
          <label onChange={fileChangeHandler} htmlFor="file">
            <input name="" type="file" id="file" hidden />
            <p>{!selectedFile ? "Select File" : `${selectedFile.name}`}</p>
          </label>
          <label>Name</label>
          <input
            type="text"
            placeholder="Cool NFT"
            onChange={nameChangeHandler}
          />
          <label>Description</label>
          <input
            type="text"
            placeholder="This NFT is just so cool"
            onChange={descriptionChangeHandler}
          />
          <label>Your Website</label>
          <input
            type="text"
            placeholder="https://pinata.cloud"
            onChange={externalURLChangeHandler}
          />
          <button onClick={handleSubmission}>Submit</button>
        </>
      )}
      {isLoading && (
        <div>
          <h2>{message}</h2>
        </div>
      )}
      {isComplete && (
        <div>
          <h4>{message}</h4>
          <a href={osLink} target="_blank" rel="noreferrer">
            <h3>Link to NFT</h3>
          </a>
          <button onClick={() => setIsComplete(false)}>Mint Another NFT</button>
        </div>
      )}
    </div>
  );
};

export default Form;

There’s a good bit going on there but nothing too complicated so let’s walk though it. First we’re importing useState to handle our different form fields and files, useAccount to get the current address for the wallet connected which we’ll using in our yet to be made minting function, and a ConnectButton for our users to connect their wallet. We’ll take our useState and setup a bunch of different states for field forms and files, and create functions to handle the changes in the form. We’ll make a minting function which we’ll fill out later, but we’ll go ahead and add in some variables we will reassign during use.

In the JSX we’ll render some conditional logic where the user will first see a connect wallet button. We also will show the form by default while the user is connected, and change that state once we are loading and completing our minting down the road. We also have a dynamic message that will let the user know the status of the minting process, from uploading to minting to completed.

For this project’s functions that involve secrets, we’re going to use the Next.js API routes and run serverless functions so we don’t have to expose anything in the client. There is one small problem though, there is a file limit size of what can be passed through those routes of 4MB which is pretty limiting for people wanting to mint an NFT. Thankfully with Pinata, we can create a temporary API key using our primary API key which will be scoped to two uses and then expire. This way we can do a client side upload for the benefits there, and not worry about exposing our used up key.

To accomplish this we’re going to create a new file under the /api/ route called key.js and delete the default hello.js file that came with the template. In that file we’ll past the following code:

//key.js

import { v4 as uuidv4 } from "uuid";
const pinataJWT = process.env.PINATA_JWT;

export default async function handler(req, res) {
  if (req.method === "GET") {
    try {
      const uuid = uuidv4();
      const body = JSON.stringify({
        keyName: uuid.toString(),
        permissions: {
          endpoints: {
            pinning: {
              pinFileToIPFS: true,
              pinJSONToIPFS: true,
            }
          }
        },
        maxUses: 2,
      });
      const keyRes = await fetch(
        "https://api.pinata.cloud/users/generateApiKey",
        {
          method: "POST",
          body: body,
          headers: {
            accept: "application/json",
            "content-type": "application/json",
            authorization: `Bearer ${pinataJWT}`,
          },
        }
      );
      const keyResJson = await keyRes.json();
			const keyData = {
				pinata_api_key: keyResJson.pinata_api_key,
				JWT: keyResJson.JWT
			}
      return res.send(keyData);
    } catch (error) {
      console.log(error.message);
      res.status(500).json({ text: "Error creating API Key", error: error });
    }
  } else if (req.method === "PUT") {
    try {
			const body = JSON.stringify(req.body)
      const keyDelete = await fetch(
        "https://api.pinata.cloud/users/revokeApiKey",
        {
          method: "PUT",
          body: body,
          headers: {
            accept: "application/json",
            "content-type": "application/json",
            authorization: `Bearer ${pinataJWT}`,
          },
        }
      );
      const keyDeleteRes = await keyDelete.json();
      return res.send(keyDeleteRes);
    } catch (error) {
      console.log(error.message);
      res.status(500).json({ text: "Error deleting API Key", error: error });
    }
  }
}

Our serverless function is going to have two methods, GET and PUT. If we use GET from the client side we’ll get a key, and if we use PUT we’ll delete the key just to keep things tidy in our Pinata account. At the top we’ll import UUID just to keep the key names unique, and we’ll import our primary Pinata API key that we don’t want to leak to the client. With this key we’ll make our temporary keys.

The GET method will be pretty simple, we’ll create uuid , then make a JSON body payload with the keyName using that uuid, give it just the two endpoints we need, then make the maxUses 2 which is all we’ll need for our uploading. Then we’ll make an API request to https://api.pinata.cloud/users/generateApiKey and send the client our result.

The PUT method will accept the client request body containing the API key to delete then call https://api.pinata.cloud/users/revokeApiKey. That’s it!

Back in our Form.js file we’ll go into the handleSubmission function and make a request to our /api/key endpoint to get our temporary Pinata JWT. We’ll also start the loading sequence with setIsLoading.

//Form.js

const handleSubmission = async () => {
  let key;
  let keyId;
  let fileCID;
  let uri;

  try {
    setIsLoading(true);
    try {
      const tempKey = await fetch("/api/key", {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
        },
      });
      const keyData = await tempKey.json();
      key = keyData.JWT;
      keyId = keyData.pinata_api_key;
    } catch (error) {
      console.log("error making API key:", error);
    }
  } catch (error) {
    console.log(error);
  }
};

Now that we have a temporary Pinata API key, we can start uploading the NFT media and metadata. There are a lot of reasons to put to put your NFT content on IPFS, and thankfully Pinata makes this easy. First we’ll upload the file that the user chose through the form.

//Form.js

const handleSubmission = async () => {
  // Rest of the code
  try {
    const formData = new FormData();
    formData.append("file", selectedFile, { filepath: selectedFile.name });

    const metadata = JSON.stringify({
      name: `${selectedFile.name}`,
    });
    formData.append("pinataMetadata", metadata);

    const options = JSON.stringify({
      cidVersion: 0,
    });
    formData.append("pinataOptions", options);

    setMessage("Uploading File...");
    const uploadRes = await fetch(
      "https://api.pinata.cloud/pinning/pinFileToIPFS",
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${key}`,
        },
        body: formData,
      }
    );
    const uploadResJson = await uploadRes.json();
    fileCID = uploadResJson.IpfsHash;
  } catch (error) {
    console.log("Error uploading file:", error);
  }
};

With this function we’re doing a standard upload through the API using our temporary Pinata API key, using formData to append our file to the request and give it a name. We also have the optional pinataMetadata where we can give the file a name in our Pinata account to keep track of later, and we can specify the CID version too. After that we take the IPFS CID result from the upload and set that as fileCID to be used in our metadata function.

In the metadata function we can take advantage of Pinata’s pinJSONToIPFS endpoint which makes it easy to upload raw JSON and get a CID back; exactly what we need to mint our NFT.

//Form.js

const handleSubmission = async () => {
  // Rest of the code
  try {
    const jsonData = JSON.stringify({
      name: name,
      description: description,
      image: process.env.NEXT_PUBLIC_PINATA_DEDICATED_GATEWAY + fileCID,
      external_url: externalURL,
    });

    setMessage("Uploading Metadata...");

    const jsonRes = await fetch(
      "https://api.pinata.cloud/pinning/pinJSONToIPFS",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${key}`,
        },
        body: jsonData,
      }
    );
    const jsonResData = await jsonRes.json();
    uri = jsonResData.IpfsHash;
  } catch (error) {
    console.log("Error uploading metadata:", error);
  }
};

Here we are building the NFT metadata which will be passed as the Token URI when the minting happens; this is all the information regarding the NFT. With our state variables we can just pass in the name, description, and externalURL. For the image we’ll process the Dedicated Gateway URL from our env.local file and append the fileCID we just got earlier. Once the file is uploaded we’ll take resulting CID for the metadata and assign it to uri.

Now the fun part: minting! Like we said earlier we will be using Crossmint for this part, and because we don’t have to do any major uploads we can run the minting function on the server side with our api routes. Go ahead and create a file called mint.js in the /api/ folder and paste in the following:

// mint.js

const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export default async function handler(req, res) {
  try {
    const data = JSON.stringify({
      recipient: `base:${req.body.address}`,
      metadata: req.body.uri,
    });

    const mintRes = await fetch(
      `https://www.crossmint.com/api/2022-06-09/collections/${process.env.CROSSMINT_COLLECTION_ID}/nfts`,
      {
        method: "POST",
        headers: {
          accept: "application/json",
          "content-type": "application/json",
          "x-client-secret": `${process.env.CROSSMINT_CLIENT_SECRET}`,
          "x-project-id": `${process.env.CROSSMINT_PROJECT_ID}`,
        },
        body: data,
      }
    );

    const mintResJson = await mintRes.json();

    if (mintResJson.onChain.status === "pending") {
      while (true) {
        await delay(5000);

        const mintStatus = await fetch(
          `https://www.crossmint.com/api/2022-06-09/collections/${process.env.CROSSMINT_COLLECTION_ID}/nfts/${mintResJson.id}`,
          {
            method: "GET",
            headers: {
              accept: "application/json",
              "x-client-secret": `${process.env.CROSSMINT_CLIENT_SECRET}`,
              "x-project-id": `${process.env.CROSSMINT_PROJECT_ID}`,
            },
          }
        );

        const mintStatusJson = await mintStatus.json();

        if (mintStatusJson.onChain.status === "success") {
          console.log(mintStatusJson);
          res.status(200).json(mintStatusJson);
          return;
        }
      }
    }
  } catch (error) {
    console.log(error);
    res.status(500).json({ text: "Error minting NFT", error: error });
  }
}

Let’s break down everything that’s happening here. First we’ll make a simple delay() function that will let us pause between running code (we’ll get to why in  a second). Next we’ll have the actual API minting function run which is gonna do a few things.

First we’ll make a JSON payload with the recipient (our user with their wallet address) and the token URI that we just made earlier. Then we’ll send that data off to Crossmint in our secure serverless backend, then wait for the data. Crossmint will send a response before the NFT is actually finished minting, so we’ll do something fun here. If onChain.status === "pending" we’ll run a while loop as long as that status is true. In that loop we’ll make a quick delay of 5 seconds, and then check again if the NFT is done minting. If it is, then we’ll send the full info about the NFT back to our client. We don’t necessarily have to do this because you can use the smart contract address, but if we do this then we can return the token ID for the NFT and give the end user a better UX. You’ll see in a bit 😁 Now we can go back to our main minting function on the client side and make the request to our new api route.

//Form.js

const handleSubmission = async () => {
  // Rest of the code
  try {
    const mintBody = JSON.stringify({
      address: address,
      uri: process.env.NEXT_PUBLIC_PINATA_DEDICATED_GATEWAY + uri,
    });

    setMessage("Minting NFT...");
    const mintRes = await fetch("/api/mint", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: mintBody,
    });
    const mintResData = await mintRes.json();
    setOsLink(
      `https://opensea.io/assets/base/${mintResData.onChain.contractAddress}/${mintResData.onChain.tokenId}`
    );
  } catch (error) {
    console.log("Error minting NFT:", error);
  }
};

Most of the hard work is being done on the server side, so all we have to do is pass our wallet

address, and the token URI link using the Pinata Dedicated Gateway plus our uri variable. From there we just make the request, get the result back, and from there build an OpenSea URL that we can give to the user and they can view their NFT.

After we have everything together it should look something like this:

import { useState } from 'react'
import { useAccount } from 'wagmi'
import { ConnectButton } from '@rainbow-me/rainbowkit';

const Form = () => {
  const [selectedFile, setSelectedFile] = useState()
  const [name, setName] = useState()
  const [description, setDescription] = useState()
  const [externalURL, setExternalURL] = useState()
  const [osLink, setOsLink] = useState("https://opensea.io")
  const [isLoading, setIsLoading] = useState(false)
  const [message, setMessage] = useState("")
  const [isComplete, setIsComplete] = useState(false)

  const { address } = useAccount()

  const fileChangeHandler = (event) => {
    setSelectedFile(event.target.files[0])
  }
  const nameChangeHandler = (event) => {
    setName(event.target.value)
  }
  const descriptionChangeHandler = (event) => {
    setDescription(event.target.value)
  }
  const externalURLChangeHandler = (event) => {
    setExternalURL(event.target.value)
  }

  const handleSubmission = async () => {
    let key;
    let keyId;
    let fileCID;
    let uri

    try {
      setIsLoading(true)
      try {
        const tempKey = await fetch("/api/key", {
          method: 'GET',
          headers: {
            'Content-Type': 'application/json'
          }
        })
        const tempKeyJson = await tempKey.json()
        key = tempKeyJson.JWT
        keyId = tempKeyJson.pinata_api_key
      } catch (error) {
        console.log("error making API key:", error)
      }
      // ... (rest of the code here)
    } catch (error) {
      console.log(error)
      setIsLoading(false)
      setIsComplete(false)
      alert("Error Minting NFT")
    }
  }

  return (
    <div>
      <div>
        <ConnectButton />
      </div>
      {!isLoading && !isComplete && (
        <>
          <label onChange={fileChangeHandler} htmlFor="file">
            <input name="" type="file" id="file" hidden />
            <p>{!selectedFile ? "Select File" : `${selectedFile.name}`}</p>
          </label>
          <label>Name</label>
          <input type='text' placeholder='Cool NFT' onChange={nameChangeHandler} />
          <label>Description</label>
          <input
            type='text'
            placeholder='This NFT is just so cool'
            onChange={descriptionChangeHandler}
          />
          <label>Your Website</label>
          <input
            type='text'
            placeholder='https://pinata.cloud'
            onChange={externalURLChangeHandler}
          />
          <button onClick={handleSubmission}>Submit</button>
        </>
      )}
      {isLoading && (
        <div>
          <h2>{message}</h2>
        </div>
      )}
      {isComplete && (
        <div>
          <h4>{message}</h4>
          <a href={osLink} target="_blank" className={styles.link} rel="noreferrer"><h3>Link to NFT</h3></a>
          <button onClick={() => setIsComplete(false)} className={styles.logout}>Mint Another NFT</button>
        </div>
      )}
    </div>
  )
}

export default Form

And there you have it! From there you can add styles to make it more branded to your taste, or add some loading indicators for when you’re showing the isLoading state. I got one more treat for ya: run npm install canvas-confetti and add confetti.js to your src directory. Paste this code in that file:

// confetti.js

import confetti from "canvas-confetti"
var count = 200;
var defaults = {
  origin: { y: 0.7 }
};

const fireConfetti = () => {
function fire(particleRatio, opts) {
  confetti(Object.assign({}, defaults, opts, {
    particleCount: Math.floor(count * particleRatio)
  }));
}

fire(0.25, {
  spread: 26,
  startVelocity: 55,
});
fire(0.2, {
  spread: 60,
});
fire(0.35, {
  spread: 100,
  decay: 0.91,
  scalar: 0.8
});
fire(0.1, {
  spread: 120,
  startVelocity: 25,
  decay: 0.92,
  scalar: 1.2
});
fire(0.1, {
  spread: 120,
  startVelocity: 45,
});
}
export default fireConfetti

Now back in Form.js you can import it and fire after minting is complete!

// Form.js

//other imports
import fireConfetti from "../../utils/confetti";

// other code

const handleSubmission = async () => {
  // other code, skip to the minting completion

  setMessage("Minting Complete!");
  setIsLoading(false);
  setIsComplete(true);
  fireConfetti();
};

In the end you should see this when you complete the minting!

   

You did it! A fully functional Base minting app using Pinata and Crossmint. For your convenience we have also made this a starter template which you can find here. This is just the beginning of what you can do with Web3, Next.js, and Pinata. Check out our other templates and SDK here, and we can’t wait to see what you build!

Happy Pinning!

Stay up to date

Join our newsletter for the latest stories & product updates from the Pinata community.

No spam, notifications only about new products, updates and freebies. You can always unsubscribe.