Back to blog
How to Build a Farcaster Frame that Mints NFTs
Frames are still going strong after being released a few weeks ago, and one of the more popular types out there is NFT minting. Since Farcaster is an open network that allows you to have wallet addresses connected to your account, Frames open the door for developers to see what a user’s address is whenever they click a button on a frame. If you can get the address, then you can “mint” an NFT to them! In today’s tutorial, we’ll show you how to build exactly that with our new FDK (Frame Development Kit).
One thing that you should now right away is the kind of minting that this frame can do. This is not like a normal smart contract where the user interacts with the contract and signs a message or pays for the NFT. This is more of an airdrop mechanism where we get their address and we send them an NFT. There are other ways to go about different kinds of NFT mints, which we’ll get into in later tutorials.
Contract Setup
Before we can build the frame, we need to have a deployed smart contract and the private key used to do that. There are so many ways this can be done, and if you’re not sure where to start, then you could follow this tutorial on how to mint an NFT on Base. For this frame, we used a standard ERC-1155 NFT contract with minting and ownable enabled, which means only our key that deploys the contract can mint from it.
That brings us to the things you will need from the contract deployment to make this NFT frame happen. First, you’re going to need the wallet’s private key
used to deploy the smart contract. We would recommend making a fresh wallet and not using a primary wallet. You will also need some funds to cover contract deployment and gas fees, and for that we would recommend Base, as it’s affordable and has a high user base. Next, you’re going to the smart contract address
of your deployed NFT contract, and along with that, you will need the contract abi
. This is usually a JSON file found in the artifacts
folder of a deployment.
Once you have all three of these, the private key
, contract address
, and contract abi
, then we can move onto building the frame.
App Setup
To make our frame, there’s a lot of different frameworks and even languages like Go you could use. For this tutorial, we’ll use Next.js and Vercel for hosting. You will need to be a little familiar with full stack web development, and the following tools:
Pinata Account
Of course, if you’re minting NFTs, Pinata has you covered as to where you’re going to store the images and metadata JSON files. But the Pinata FDK also helps streamline the process of using images in your frame, and makes them available on a distributed network. To get started, sign up for a free Pinata account, then visit the keys page and make an API key (if you get stuck check out these docs). Then you’ll want to navigate to the gateways page and copy the pre-generated Dedicated Gateway domain. This is what will render our images from IPFS to our frame. The format of that should look something like this:
gatewayname.mypinata.cloud
Write down your API keys and your gateway domain somewhere safe until we start up our app.
Alchemy
To make read and write calls to your smart contract from your server, you will want an RPC endpoint, and Alchemy makes it easy. Just sign up for an account, create a new app, and select the network you need. Once you make one, there should be a button to view your HTTP API key that you will also want to write down somewhere safe.
Text Editor & Node.js
Apart from third party providers, you will also need a text editor like VSCode or Zed, and you will want at least Node v.18 installed.
To get started, go into your terminal and run the following command, selecting all the default options.
npx create-next-app@latest frame-nft-mint
Once everything is installed, we will want to cd
into the directory and install some dependencies.
cd frame-nft-mint && npm install pinata-sdk view
After that, you can go ahead and open the project folder in your text editor. We need to add one extra file in the root of the project called .env.local
, which is where we will want to paste the following variables.
PINATA_JWT=Your Pinata JWT from the API key creation we did earlier
GATEWAY_URL=Your Pinata Dedicated Gateway domain, e.g. dolphin.mypinata.cloud
BASE_URL=http://localhost:3000 # this will be your vercel url when you deploy
ALCHEMY_URL= The HTTP API key url from Alchemy
PRIVATE_KEY= The private key of the wallet you used to deploy the smart contract
CONTRACT_ADDRESS=The smart contract address of your NFT contract
Be sure to replace these placeholders with the actual values for your own project, and also keep in mind that when you export your private key you might need to add <span class="code-lnline">0x</span> to the front of it.
Routes
With our project open, the structure should look something like this.
.
├── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── next.svg
│ └── vercel.svg
├── README.md
├── tailwind.config.ts
└── tsconfig.json
In order to handle sending the metadata tags needed for frames to work, we’re going to use API routes in the app. Thankfully, Next.js makes this pretty easy to do, so make a new folder inside of app
called frame
, and another folder called redirect
. Inside both of those folders, add a route.ts
file inside of them. After that, your project should look like this.
.
├── app
│ ├── favicon.ico
│ ├── frame
│ │ └── route.ts
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx
│ └── redirect
│ └── route.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── next.svg
│ └── vercel.svg
├── README.md
├── tailwind.config.ts
├── tsconfig.json
└── utils
├── contract.json
├── fc.ts
└── mint.ts
Now let’s go into the frame/route.ts
file as our starting point for the frame. This will hold the majority of our code.
import { NextRequest, NextResponse } from "next/server"
import { getConnectedAddressForUser } from "@/utils/fc";
import { mintNft, balanceOf } from "@/utils/mint";
import { PinataFDK } from "pinata-fdk";
const fdk = new PinataFDK({
pinata_jwt: process.env.PINATA_JWT as string,
pinata_gateway: process.env.GATEWAY_URL as string,
});
export async function GET(req: NextRequest, res: NextResponse) {
try {
const frameMetadata = await fdk.getFrameMetadata({
post_url: `${process.env.BASE_URL}/frame`,
buttons: [{ label: "Mint NFT", action: "post" }],
aspect_ratio: "1:1",
cid: "QmSYN7KT847Nado3fxFafYZgG6NXTMZwbaMvU9jhu5nPmJ",
});
return new NextResponse(frameMetadata);
} catch (error) {
console.log(error);
return NextResponse.json({ error: error });
}
}
export async function POST(req: NextRequest, res: NextResponse) {
const body = await req.json();
const fid = body.untrustedData.fid;
const address = await getConnectedAddressForUser(fid);
const balance = await balanceOf(address);
console.log(balance);
if (typeof balance === "number" && balance !== null && balance < 1) {
try {
const mint = await mintNft(address);
console.log(mint);
const frameMetadata = await fdk.getFrameMetadata({
post_url: `${process.env.BASE_URL}/redirect`,
buttons: [{ label: "Learn How to Make This", action: "post_redirect" }],
aspect_ratio: "1:1",
cid: "QmUx3kQH4vR2t7mTmW3jHJgJgJGxjoBsMxt6z1fkZEHyHJ",
});
return new NextResponse(frameMetadata);
} catch (error) {
console.log(error);
return NextResponse.json({ error: error });
}
} else {
const frameMetadata = await fdk.getFrameMetadata({
post_url: `${process.env.BASE_URL}/redirect`,
buttons: [{ label: "Learn How to Make This", action: "post_redirect" }],
aspect_ratio: "1:1",
cid: "QmaaEbtsetwamJwfFPAQAFC6FAE1xeYsvF7EBKA8NYMjP2",
});
return new NextResponse(frameMetadata);
}
}
app/frame/route.ts
In here, we import the Pinata FDK and initialize it with our PINATA_JWT
and our GATEWAY_URL
. Then, we need to make two route handlers. The first is our GET
request, which will be the first initial frame. Inside there, we’ll create some frameMetadata
, with the post_url
pointing to the same route /frame
(including the button with the action and label), the image CID, and the aspect ratio for it. Since I uploaded my images to my Pinata account, I don’t have to worry about full sized links. I can just use the CID instead, and it will automatically get rendered by my Dedicated Gateway. Finally, we return the response and handle an error just in case.
In the POST
request, there’s certainly a bit more going on. First, we take the body of the Frame request and parse it as JSON. As stated in the Farcaster Docs, whenever someone interacts with a Frame, the client will send a payload of information about that user which looks something like this:
{
"untrustedData": {
"fid": 2,
"url": "https://fcpolls.com/polls/1",
"messageHash": "0xd2b1ddc6c88e865a33cb1a565e0058d757042974",
"timestamp": 1706243218,
"network": 1,
"buttonIndex": 2,
"inputText": "hello world", // "" if requested and no input, undefined if input not requested
"castId": {
"fid": 226,
"hash": "0xa48dd46161d8e57725f5e26e34ec19c13ff7f3b9"
}
},
"trustedData": {
"messageBytes": "d2b1ddc6c88e865a33cb1a565e0058d757042974..."
}
}
In order to mint an NFT to the user, we need to get their wallet address. We do that by using that payload and grabbing their “FID,” with which we feed into a function we’ll make later that returns us their address. We’re also going to use function balanceOf
to make sure our user hasn’t minted an NFT already.
If we check their balance, and it’s less than one, we follow through with minting an NFT using mintNft
and passing in our address from earlier. Once the transaction is initiated, we return some Frame metadata with an image saying the mint was successful, as well as giving them a link to this blog post to see how it’s done. Alternatively, if they already have an NFT, we’ll show a different image saying they already minted and use the same link redirect.
Speaking of that redirect, let’s navigate to redirect/route.ts
and put in our code there.
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest, res: NextResponse){
try {
return NextResponse.redirect('https://pinata.cloud', { status: 302 })
} catch (error) {
console.log(error)
return NextResponse.json({ error: error })
}
}
app/redirect/routes.ts
This is much simpler, of course, as it just redirects the user to the designated URL with a status code of 302
. With our routes complete, let’s move onto our utilities that we used frame/route.ts
.
Utils
Start by making a new folder in the root of your project called utils
, and add in the files fc.ts
, mint.ts
, and contract.json
. In the contract.json
, you can go ahead and paste in the contract ABI we spoke of earlier. Depending how it’s compiled, you might have to access it through an output.abi
object, or perhaps just abi
, just make sure you check first. After making those files, the structure should look like this:
.
├── app
│ ├── favicon.ico
│ ├── frame
│ │ └── route.ts
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx
│ └── redirect
│ └── route.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ ├── next.svg
│ └── vercel.svg
├── README.md
├── tailwind.config.ts
├── tsconfig.json
└── utils
├── contract.json
├── fc.ts
└── mint.ts
Now let’s start with the fc.ts
file, and put in the following code.
export const getConnectedAddressForUser = async (fid: number) => {
const res = await fetch(`https://hub.pinata.cloud/v1/verificationsByFid?fid=${fid}`)
const json = await res.json();
const address = json.messages[0].data.verificationAddAddressBody.address
return address
}
utils/fc.ts
This is a pretty simple function that just takes in an fid
and passes it in through the Pinata Hub API endpoint that gets address verifications by fid. Then we simply parse the response and grab the first address listed. If you wanted, you could look into returning both, and then making another Frame endpoint. This gives the user an option of which address they would like to be minted to — up to you!
With that little file complete, put in this code for mint.ts
.
import { createWalletClient, http, createPublicClient } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains";
import contractAbi from "./contract.json";
const contractAddress = process.env.CONTRACT_ADDRESS as `0x`;
const account = privateKeyToAccount((process.env.PRIVATE_KEY as `0x`) || "");
export const publicClient = createPublicClient({
chain: baseSepolia,
transport: http(process.env.ALCHEMY_URL),
});
const walletClient = createWalletClient({
account,
chain: baseSepolia,
transport: http(process.env.ALCHEMY_URL),
});
export async function mintNft(toAddress: string) {
try {
const { request }: any = await publicClient.simulateContract({
account,
address: contractAddress,
abi: contractAbi.output.abi,
functionName: "mint",
args: [toAddress, 0, 1, `0x`],
});
const transaction = await walletClient.writeContract(request);
return transaction;
} catch (error) {
console.log(error);
return error;
}
}
export async function balanceOf(address: string) {
try {
const balanceData = await publicClient.readContract({
address: contractAddress,
abi: contractAbi.output.abi,
functionName: "balanceOf",
args: [address as `0x`, 0]
});
const balance: number = Number(balanceData)
return balance
} catch (error) {
console.log(error);
return error;
}
}
utils/mint.ts
This file has a lot more going on, so let’s break down each part. First, we import all our dependencies and contracts, including our contact address and our contract abi. Then we declare our account
by using the private key we setup earlier, which has access to the smart contract. With the account setup, we can make both the publicClient
and the walletClient
to make read and write calls.
Then we’ll declare a mintNft
that takes in an address and simulates a contract write for the mint function. The arguments at the bottom include our mint
function on the contract, as well as the args
of our wallet address, token ID, amount, and data. If you end up using a different contract, you might have to adjust these! After simulating, we can then call walletClient.writeContract
with our request and return the transaction hash. Pretty easy!
The last function will be our balance checker, which takes in an address again and does a simpler publicClient.readContract
call that checks the balance of the passed in address for token ID 0. This will return a BigInt
, so we'll parse it with Number()
and return that value as the balance. With everything put together, we can now deploy this to Vercel.
Deployment
Since we are deploying to Vercel, the process is pretty easy from here, but if you haven’t done it before, it might be helpful to watch this video first. In short, upload this repository to GitHub and make sure it’s up to date with all your commits and changes. Then head to Vercel, start a new project, and select the repo from your list of repos via the GitHub connection. When creating it, be sure to copy in your variables from .env.local
that we used earlier into the Environment Variables
section of the deployment. Remember, that BASE_URL
might depend on the URL Vercel gives you as the host of your project, so if you’re not sure what will be, you can go back and update it in settings after deployment.
Once it’s deployed, you should be able to visit the Frames Validation Tool and test to make sure it’s working! Remember, since our starting point is /frame
, your starting URL should be something like https://frame-minting-tutorial.vercel.app/frame
Conclusion
That wraps up this tutorial! This is just the beginning of what you can do with Frames, and I would highly recommend checking out our open sourced repo full of frames that the Pinata team has built over the past few weeks, as well as the repo for this tutorial as a starting template. Be sure to play around with the Pinata FDK, as it has some cool features like uploading content to IPFS for you. 👀 Whatever you build, be sure to post your achievements in /pinata!
Happy Pinning!