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 <span class="code-inline">Project ID</span> and <span class="code-inline">Collection ID</span> 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 <span class="code-inline">Project ID</span> and your <span class="code-inline">Client Secret</span> ; 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 <span class="code-inline">Project ID</span> 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 <span class="code-inline">pages</span> router. Once that’s complete and you <span class="code-inline">cd</span> 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 <span class="code-inline">.env.local</span> 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: <span class="code-inline">https://your-domain-name.mypinata.cloud/ipfs/</span>.
With our secrets and keys ready to go, let’s open up the <span class="code-inline">_app.js</span> 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 <span class="code-inline">useEffect</span> and <span class="code-inline">useState</span>.
One small thing we need to adjust for Rainbow Kit to work is the <span class="code-inline">next.config.js</span> 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 <span class="code-inline">index.js</span> 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 <span class="code-inline">ConnectButton</span> from Rainbow Kit, and a <span class="code-inline">useAccount</span> hook from Wagmi. Then we’ll extract <span class="code-inline">isConnected</span> from <span class="code-inline">useAccount</span> 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 <span class="code-inline">Form</span> 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 <span class="code-inline">/components</span> directory in your <span class="code-inline">src</span> folder, then add <span class="code-inline">Form.js</span> 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 <span class="code-inline">useState</span> to handle our different form fields and files, <span class="code-inline">useAccount</span> to get the current address for the wallet connected which we’ll using in our yet to be made minting function, and a <span class="code-inline">ConnectButton</span> for our users to connect their wallet. We’ll take our <span class="code-inline">useState</span> 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 <span class="code-inline">/api/</span> route called <span class="code-inline">key.js</span> and delete the default <span class="code-inline">hello.js</span> 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, <span class="code-inline">GET</span> and <span class="code-inline">PUT</span>. If we use <span class="code-inline">GET</span> from the client side we’ll get a key, and if we use <span class="code-inline">PUT</span> we’ll delete the key just to keep things tidy in our Pinata account. At the top we’ll import <span class="code-inline">UUID</span> 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 <span class="code-inline">GET</span> method will be pretty simple, we’ll create <span class="code-inline">uuid</span> , then make a JSON body payload with the <span class="code-inline">keyName</span> using that <span class="code-inline">uuid</span>, give it just the two endpoints we need, then make the <span class="code-inline">maxUses</span> 2 which is all we’ll need for our uploading. Then we’ll make an API request to <span class="code-inline">https://api.pinata.cloud/users/generateApiKey</span> and send the client our result.
The <span class="code-inline">PUT</span> method will accept the client request body containing the API key to delete then call <span class="code-inline">https://api.pinata.cloud/users/revokeApiKey</span>. That’s it!
Back in our <span class="code-inline">Form.js</span> file we’ll go into the <span class="code-inline">handleSubmission</span> function and make a request to our <span class="code-inline">/api/key</span> endpoint to get our temporary Pinata JWT. We’ll also start the loading sequence with <span class="code-inline">setIsLoading</span>.
//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 <span class="code-inline">formData</span> to append our file to the request and give it a name. We also have the optional <span class="code-inline">pinataMetadata</span> 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 <span class="code-inline">fileCID</span> to be used in our metadata function.
In the metadata function we can take advantage of Pinata’s <span class="code-inline">pinJSONToIPFS</span> 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 <span class="code-inline">name</span>, <span class="code-inline">description</span>, and <span class="code-inline">externalURL</span>. For the image we’ll process the Dedicated Gateway URL from our <span class="code-inline">env.local</span> file and append the <span class="code-inline">fileCID</span> we just got earlier. Once the file is uploaded we’ll take resulting CID for the metadata and assign it to <span class="code-inline">uri</span>.
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 <span class="code-inline">api</span> routes. Go ahead and create a file called <span class="code-inline">mint.js</span> in the <span class="code-inline">/api/</span> 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 <span class="code-inline">delay()</span> 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 <span class="code-inline">onChain.status === "pending"</span> we’ll run a <span class="code-inline">while loop</span> as long as that status is <span class="code-inline">true</span>. 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
<span class="code-inline">address</span>, and the token URI link using the Pinata Dedicated Gateway plus our <span class="code-inline">uri</span> 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 <span class="code-inline">isLoading</span> state. I got one more treat for ya: run <span class="code-inline">npm install canvas-confetti</span> and add <span class="code-inline">confetti.js</span> to your <span class="code-inline">src</span> 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 <span class="code-inline">Form.js</span> 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!