Blog home

Build an app to mint Base NFTs from Farcaster Posts

Steve

Published on

25 min read

Build an app to mint Base NFTs from Farcaster Posts

Transform Farcaster casts into NFTs on Base without gas fees using Pinata. Start minting your favorite content today!

The worldwide Web3 is taking off again but in a different direction. In the last bull run, we saw a huge focus on NFTs - 10K collections of various images with rare traits. It was fun, but that particular NFT model was not the most sustainable run. Nowadays, Web3 apps are taking off with multiple use cases such as decentralized social media, redoing the music industry, or even blockchain gaming.

One of those cases is Farcaster (which we’ve talked about before), a decentralized social media platform, similar to X or Mastadon, but with several twists. It allows many different clients to offer and compete multiple features, helping improve the experience for the users. Some of the most influential minds in the space are using Farcaster, including people like Vitalek Buterin. If you scroll through his feed you might see some great posts you’d like to save, and what better way than turning them into an NFT!

In this tutorial, we’ll show you how we made Pocketcast.cloud, a simple app that takes casts from Farcaster and mints them as NFTs for collecting, trading, or sharing. For fun, we started out the concepts on a platform called Val.town which we’ll get into later. We also used Base, a layer 2 optimistic rollup blockchain on top of Ethereum that provides speed and cheap gas fees. To mint on Base we’ll also utilize Crossmint, which we have featured before with their NFT minting API. The combination is something so smooth and fun that you have to try.

Let’s get started!

Val.Town

Most of our coding will actually be outside of a code editor and instead will be on something called Val.town. Imagine GitHub gists combined with AWS lambdas and other neat features like cron jobs, sending emails, and even making REST APIs. It’s perfect when you want to automate a small task and don’t want to spin up a whole server or if you want to experiment before moving to a larger infrastructure. Granted, this project got a little long-winded, but it’s helpful to envision all the moving pieces as individual modules that can be swapped out and used in other projects. You can sign up for a free account for this tutorial and get rolling. Some other stuff you’ll need later on in the tutorial would include the following:

With that said, let’s get started.

Fetching Cast Info with Wield.co

In the Farcaster ecosystem, there are hubs that are run by community members to keep the network running. It’s how you access and write information to the network. If you want to fetch network, you could try running your own hub or use a service like wield.co and get free read access with their API, and that’s exactly what we’ll do!

To get an API key you’ll need to make this API request with your own info:

curl -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "description": "YOUR_DESCRIPTION",
    "email": "YOUR_EMAIL"
  }' \
  https://protocol.wield.co/apikey/create

It will give a response with your API key which we’ll use shortly.

There are several ways we could go about turning a Farcaster URL into an NFT, but the easiest we found was using the Warpcast default URL. This Farcaster client structures their URLs as follows:

https://warpcast.com/dwr.eth/0x42979bb9

As you can probably tell, we have the base URL, the username, and then what’s called the “short hash.” Wield.co has an API endpoint that fetches cast into with the username and that same short hash - perfect! From there we can make a pretty simple function in Val.town to use.

const KEY = Deno.env.get("YOUR_WEILD_KEY_ENV");

export default async function fetchCast(shortHash, username) {
  try {
    const res = await fetch(
      `https://protocol.wield.co/farcaster/v2/cast-short?shortHash=${shortHash}<username=${username}`,
      {
        method: "GET",
        headers: {
          contentType: "application/json",
          "API-KEY": KEY,
        },
      },
    );
    const resJson = await res.json();
    console.log(resJson.result.cast);
    return resJson.result.cast;
  } catch (error) {
    console.log(error);
    throw error;
  }
}

If you look at the top you will see that we are importing an environment variable at the top, which is something Val.town supports. To add your own, simply click on the profile button in the top right and select “Env Variables” to add your API key with whatever name you want.

You’ll also notice this is structured as an exported function, and that brings us to one of Val.town’s best features which is importing vals into other vals. Simply click on the three dots in the top right corner, go to “Copy”, then select “Module URL.”

Now that we have our raw cast data we can start feeding it into our next step of turning it into an SVG.

Turning Cast Data Into an SVG with Satori

One of my favorite libraries is Satori by Vercel. It’s primarily used for making dynamic Open Graph images, what you usually see when you post a link to a blog post and the embed image has the title of the blog and maybe the description. Satori takes JSX code and turns it into SVGs, and because it is JSX, we can program it to take dynamic attributes. In order for vals to use JSX, we’ll have to paste one line of code at the top of the val. We’ll also import our NPM packages and a little library solution that I found to make emojis work, which you can check out here.

/** @jsxImportSource https://esm.sh/preact */
import * as path from "https://deno.land/[email protected]/path/mod.ts";
import { getIconCode, loadEmoji } from "https://esm.town/v/stevedylandev/twemoji";
import { Resvg } from "npm:@resvg/resvg-js";
import satori from "npm:satori";

For our cast NFT, we want to include most of the same things we see when we view it on Warpcast, plus a little extra. To do that, we’ll pass in a profile picture, name, username, date, cast, shortHash, and image (if applicable). Altogether, we get the following val:

/** @jsxImportSource https://esm.sh/preact */
import * as path from "https://deno.land/[email protected]/path/mod.ts";
import { getIconCode, loadEmoji } from "https://esm.town/v/stevedylandev/twemoji";
import { Resvg } from "npm:@resvg/resvg-js";
import satori from "npm:satori";

const robotoFetch400 = await fetch(
  "https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-400-normal.ttf",
);
const robotoFetch700 = await fetch(
  "https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-700-normal.ttf",
);

const options = {
  width: 1080,
  height: 1080,
  fonts: [
    {
      name: "Roboto",
      // Use `fs` (Node.js only) or `fetch` to read the font as Buffer/ArrayBuffer and provide `data` here.
      data: await robotoFetch400.arrayBuffer(),
      weight: 400,
      style: "normal",
    },
    {
      name: "Roboto",
      // Use `fs` (Node.js only) or `fetch` to read the font as Buffer/ArrayBuffer and provide `data` here.
      data: await robotoFetch700.arrayBuffer(),
      weight: 700,
      style: "bold",
    },
  ],
  loadAdditionalAsset: async (code, segment) => {
    if (code === "emoji") {
      return (`data:image/svg+xml;base64,${btoa(await loadEmoji("twemoji", getIconCode(segment)))}`);
    }
  },
};

export default async function satoriHandler(pfp, name, username, date, cast, shortHash, image) {
  try {
    const svg = await satori(
      <div
  style={{
    padding: "3rem",
    boxSizing: "border-box",
    height: "100%",
    width: "100%",
    display: "flex",
    flexDirection: "column",
    alignItems: "flex-start",
    justifyContent: "space-between",
    backgroundColor: "#fff",
    fontSize: 32,
    fontWeight: 400,
    gap: "2rem",
  }}
>
  <div
    style={{
      display: "flex",
      justifyContent: "center",
      alignItems: "center",
    }}
  >
    <img
      src={pfp}
      alt="pfp"
      style={{
        width: 75,
        height: 75,
        marginRight: "1rem",
        borderRadius: "50%",
        border: "1px solid gray",
      }}
    />
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        justifyContent: "center",
      }}
    >
      <div style={{ display: "flex", fontSize: 24, fontWeight: 700 }}>
        {name}
      </div>
      <div
        style={{
          display: "flex",
          fontSize: 24,
          fontWeight: 400,
          color: "gray",
        }}
      >
        @{username} • {date}
      </div>
    </div>
  </div>
  <div style={{ display: "flex", fontWeight: 400 }}>{cast}</div>
  {image ? (
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        margin: "auto",
        width: "500px",
        height: "auto",
        objectFit: "contain",
      }}
    >
      <img
        style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }}
        src={image}
        alt="image post"
      />
    </div>
  ) : null}
  <div
    style={{
      display: "flex",
      justifyContent: "flex-end",
      alignItems: "center",
      width: "100%",
      color: "gray",
      fontWeight: "400",
      fontSize: 24,
    }}
  >
    warpcast.com/{username}/{shortHash}
    <svg
      style={{ borderRadius: "15px", marginLeft: "1rem" }}
      width="75"
      height="75"
      viewBox="0 0 1080 1080"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <rect width="1080" height="1080" fill="#6D57FF" />
      <path
        d="M432.378 123H681.867V477.778H645.244V315.267H644.885C640.838 270.352 603.09 235.156 557.122 235.156C511.154 235.156 473.407 270.352 469.359 315.267H469V477.778H432.378V123Z"
        fill="white"
      />
      <path
        d="M366 173.356L380.878 223.711H393.467V427.422C387.146 427.422 382.022 432.546 382.022 438.867V452.6H379.733C373.413 452.6 368.289 457.724 368.289 464.044V477.778H496.467V464.044C496.467 457.724 491.343 452.6 485.022 452.6H482.733V438.867C482.733 432.546 477.609 427.422 471.289 427.422H457.556V173.356H366Z"
        fill="white"
      />
      <path
        d="M647.533 427.422C641.213 427.422 636.089 432.546 636.089 438.867V452.6H633.8C627.479 452.6 622.356 457.724 622.356 464.044V477.778H750.533V464.044C750.533 457.724 745.409 452.6 739.089 452.6H736.8V438.867C736.8 432.546 731.676 427.422 725.356 427.422V223.711H737.944L752.822 173.356H661.267V427.422H647.533Z"
        fill="white"
      />
      <path
        d="M277 279H840.867V794.586L558.934 957.403L277 794.586V279ZM770.384 490.45H347.483V754.763L558.934 875.994L770.384 754.763V490.45ZM770.384 349.483H347.483V419.967H770.384V349.483Z"
        fill="#A79AFF"
      />
      <path
        d="M770.384 490.45H347.483V754.763L558.934 875.994L770.384 754.763V490.45Z"
        fill="#8A79FF"
      />
      <path
        d="M770.384 349.483H347.483V419.967H770.384V349.483Z"
        fill="#8A79FF"
      />
    </svg>
  </div>
</div>,
      options,
    );
    console.log(svg);
    return svg;
  } catch (error) {
    console.log(error);
    throw error;
  }
}

It’s a little long-winded due to the styles required to make it look right, but in the end, we get an image that looks like this.

Nice!! With our NFT image ready to go, it’s onto uploading it with some metadata to IPFS.

Uploading Image and Metadata to IPFS with Pinata

IPFS is a great pairing with blockchains as it provides an immutable off-chain storage solution, as well as other benefits such as content addressing and portability. Pinata makes uploading in this flow really easy to do with its API; just follow the instructions here to make an account and get an API key. We’ll be using the longer JWT so be sure to save that one in particular. Add it to your Val.town Env Variables just like we did for Wield.co.

Before uploading the image, we’ll convert it to a png using resvg, as pngs will have fewer compatibility issues when being shared on other platforms. You could, of course, skip this step if you prefer otherwise. We’ll make a function to handle this, as well as take in the other parameters for our metadata JSON that we’ll upload too.

import { render } from "https://deno.land/x/resvg_wasm/mod.ts";
const JWT = Deno.env.get("YOUR_PINATA_JWT_ENV");

export default async function contentUploadHandler(
  svg,
  shortHash,
  username,
  rawCastData,
) {
  try {
    const image = await render(svg);
    const file = new File([image], "image.png");
    const data = new FormData();
    data.append("file", file, {
      filepath: "image.png",
    });
    const imageUpload = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${JWT}`,
      },
      body: data,
    });
    const { IpfsHash } = await imageUpload.json();

    const json = JSON.stringify({
      pinataContent: {
        name: `Cast ${shortHash}`,
        description: `A cast by ${username} preserved through IPFS and NFTs`,
        image: `ipfs://${IpfsHash}`,
        external_url: "https://pocketcast.cloud",
        attributes: [
          {
            trait_type: "cast_data",
            value: rawCastData,
          },
        ],
      },
    });

    const jsonUpload = await fetch("https://api.pinata.cloud/pinning/pinJSONToIPFS", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "accept": "application/json",
        "Authorization": `Bearer ${JWT}`,
      },
      body: json,
    });
    const jsonRes = await jsonUpload.json();
    return jsonRes.IpfsHash;
  } catch (error) {
    console.log(error);
    throw error;
  }
}

Resvg gives us a blob after conversion, so we’ll turn that info into a file and append it to the API request to Pinata. This upload will give us a CID in return, with which we’ll pass it into a JSON object that will be our NFT metadata. Then we can just upload the raw JSON to Pinata with the pinJSONToIPFS endpoint - easy! This will finally give us the metadata CID, which will be our NFT Token URI.

Minting with Crossmint

Something that can be very difficult to set up and handle is the actual NFT minting process. You have to set up a smart contract, make sure your wallet library works with your frontend app, and then your users have to mess with gas. Crossmint simplifies this with their NFT minting API, with support on multiple networks, and even sending NFTs to an email address vs a wallet address. To get started with them, you will need to sign up for an account, and if you want to mint on Base, then you would need to add some credits, but you could easily test this on Polygon Mumbai testnet. After making an account, you’ll want to make an API key as well as a new collection if you are not on testnet.

The rest is really easy. We’ll make a new Val and import our Collection ID and API Key as Env variables, then write a function to make the API call.

const CROSSMINT_COLLECTION_ID = Deno.env.get("YOUR_CROSSMINT_COLLECTION_ID_ENV");
const CROSSMINT_API_KEY = Deno.env.get("YOUR_CROSSMINT_API_KEY_ENV");

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

export default async function castMintHandler(cid, address) {
  try {
    const data = JSON.stringify({
      metadata: `https://example.mypinata.cloud/ipfs/${cid}`,
      recipient: `base:${address}`,
    });
    const mintRes = await fetch(
      `https://www.crossmint.com/api/2022-06-09/collections/${CROSSMINT_COLLECTION_ID}/nfts`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-API-KEY": CROSSMINT_API_KEY,
        },
        body: data,
      },
    );
    const mintResJson = await mintRes.json();
    console.log(mintResJson);
    if (mintResJson.onChain.status === "pending") {
      while (true) {
        await delay(5000);

        const mintStatus = await fetch(
          `https://www.crossmint.com/api/2022-06-09/collections/${CROSSMINT_COLLECTION_ID}/nfts/${mintResJson.id}`,
          {
            method: "GET",
            headers: {
              accept: "application/json",
              "X-API-KEY": `${CROSSMINT_API_KEY}`,
            },
          },
        );

        const mintStatusJson = await mintStatus.json();
        console.log(mintStatusJson);

        if (mintStatusJson.onChain.status === "success") {
          console.log(mintStatusJson);
          return mintStatusJson.onChain;
        }
      }
    }
  } catch (error) {
    console.log(error);
    throw error;
  }
}

A few things to notice here. In the function, we just take in the CID for our Token URI, and we take in a wallet address. Crossmint also handles ENS resolution, which is a huge plus. In the request payload, we just have to designate the metadata URL where we’ll pass in the CID to an IPFS Dedicated Gateway link, the recipient with our chain, and then the address. When we send the request to Crossmint we’ll get an instant response that gives us a status of “pending.” We want to give the end user a link to the NFT, so we’ll want to wait until it’s done. To do that, we’ve added a small wait function to wait 5 seconds before sending another request to check the status of our last request. Once it’s complete, we’ll finish the loop and return the data about the NFT.

Making the API

End-to-end, we have all the functions we need to turn casts into NFTs. Now we need to pull it all together inside an API that we can make requests to from a front-end app. Val.town makes this easy with their API starter modules, and they have lots of API library options to choose from. For this one, we tried out nhttp, as it seemed pretty easy to implement. We’ll import all of our functions via the val module links, set up a get and post route, and then daisy-chain our data together.

import { nhttp } from "npm:nhttp-land@1";
import fetchCast from "https://esm.town/v/stevedylandev/fetchCastBlog"
import satoriHandler from "https://esm.town/v/stevedylandev/satoriHandlerBlog"
import contentUploadHandler from "https://esm.town/v/stevedylandev/contentUploadHandlerBlog"
import castMintHandler from "https://esm.town/v/stevedylandev/castMintHanderBlog"

export const pocketCastApi = async (request) => {
  const app = nhttp();
  app.get("/", () => {
    return "Make a post request to /mint with your cast url and address as query params";
  });
  app.post("/mint", async () => {
    try {
      const searchParams = new URL(request.url).searchParams;
      const castUrl = searchParams.get("cast")
      if (!castUrl.startsWith("https://warpcast.com")) {
        return {
          status: 400,
          body: "Invalid cast URL. URL must start with 'https://warpcast.com'."
        };
      }
      const address = searchParams.get("address")
      const parts = castUrl.split('/');
      const username = parts[3];
      const hash = parts[4];
      const rawCast = await fetchCast(hash, username)
      const date = new Date(rawCast.timestamp).toDateString()
      const svg = await satoriHandler(
        rawCast.author.pfp.url,
        rawCast.author.displayName,
        rawCast.author.username,
        date,
        rawCast.text,
        hash,
        rawCast.embeds.images[0] ? rawCast.embeds.images[0].url : ""
      )
      const cid = await contentUploadHandler(
        svg,
        hash,
        username,
        rawCast
      )
      console.log(cid)
      const nft = await castMintHandler(cid, address)
      console.log(cid)
      return nft;
    } catch (error){
      console.log(error)
      return {
        status: 500,
        body: error.message
      };
    }
  });
  return app.handleRequest(request);
};

To start, we’ll init nhttp and make a simple get route as val will continue to hit it anytime you save and click run, and this will just return some instructions. Next, we’ll make our post endpoint grab the query params in our URL. We can just pass in our Warpcast URL and wallet address, like so:

https://stevedylandev-pocketCastApi.web.val.run/cast?cast=https://warpcast.com/dwr.eth/0x42979bb9<address=stevedylandev.eth

We’ll run a quick check to make sure the cast URL starts with Warpcast and return an error if it doesn’t. Then we’ll grab the address and parse the Warpcast URL data into the short hash and username, which we talked about earlier. Now we just call each function and feed the results into the next function in the chain: get the raw cast data, pass it into the satoriHandler, feed the SVG from satoriHandler into the content uploader along with our username, short hash, and raw cast data, then feed the CID from the content uploader to the NFT minting function.

Whew! As I said, this gets a bit out of hand when you start transitioning into a full-blown app, but the beauty is that all of these pieces can easily be transitioned into a separate server or even a Next.js app; and you still have individual modules that can be used elsewhere in other creative ways. It’s a great way to evolve from tinkering to building!

Building a Front-End App

All the hard work is done. Now all we need is a front-end app where users can interact with it. To make that happen, we’ll use Next.js thanks to its ability to handle its own server-side API calls and keep our data safe. To start the project, you can go to your terminal and run:

npx create-next-app@latest

We’ll be using the app router, along with typescript and tailwind, but feel free to change those as you please. The only real difference you might see is with the structure if you choose the page router.

We’re also going to use shadcn/ui, which is a free open-source UI library, and it’s simply fantastic. To install it, follow the instructions here. Once everything is installed, we can start setting up the project by editing the layout.tsx and making sure shadcn is ready.

import './globals.css'
import { Inter as FontSans } from "next/font/google"
import { cn } from "@/lib/utils"

const fontSans = FontSans({
  subsets: ["latin"],
  variable: "--font-sans",
})

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head>
        <link rel="icon" href="/logo.svg" sizes="any" />
      </head>
      <body
      className={cn(
          "min-h-screen bg-background font-sans antialiased",
          fontSans.variable
        )}
      >
        {children}
      </body>
    </html>
  )
}

layout.tsx

Next, we’ll edit our main page.tsx by removing all the boilerplate and using some basic styles, headers, and titles. We’ll also go ahead and import CastForm, which is a component we’ll build next, which has lots stuff going on.

import Image from "next/image";
import CastForm from "@/components/CastForm";
import Link from "next/link"

export default function Home() {
  return (
    <main className="w-[90%] lg:w-full min-h-screen m-auto gap-4 flex flex-col justify-center items-center font-sans">
      <h1 className="font-bold text-4xl">PocketCast</h1>
      <Image 
        src="/logo.svg" 
        alt="PocketCast Logo" 
        className="rounded-lg lg:w-96 w-80"
        width={1080}
        height={1080} />
      <h3 className="lg:w-96 w-80 mb-2">Mint your favorite casts from <Link className="text-[#8465CB] font-bold" href="https://farcaster.com" target="_blank">Farcaster</Link> as NFTs on <Link href="https://base.org" target="_blank" className="text-[#2856F6] font-bold">Base</Link>, powered by <Link href="https://pinata.cloud" target="_blank" className="text-[#6A58F6] font-bold">Pinata</Link></h3>
      <CastForm />
    </main>
  );
}

page.tsx

It’s time to use some of those shadcn components, and the great thing about this library is that we can add just what we need with the CLI.

npx shadcn-ui@latest add form button input toast

This should add a whole bunch of stuff to the /components folder and the ui subfolder. Let’s go ahead and create a CastForm.tsx component inside the root of /components. Then we can put in the following code:

'use client'

import * as z from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/use-toast"
import { Toaster } from "@/components/ui/toaster"
import { Loader2 } from "lucide-react"
import { useState } from "react"
import { ToastAction } from "@/components/ui/toast"
import Link from "next/link"


const formSchema = z.object({
  castUrl: z.string().url(),
  address: z.string()
})

export function ButtonLoading() {
  return (
    <Button disabled>
      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
      Minting cast on Base...
    </Button>
  )
}

export default function CastForm() {

  const [isLoading, setIsLoading] = useState(false)

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
  })

  async function onSubmit(values: z.infer<typeof formSchema>) {
    try {
      console.log(values)
    } catch (error) {
      console.log(error)
    }
  }
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full gap-4 w-80 lg:w-96">
        <FormField
          control={form.control}
          name="castUrl"
          render={({ field }) => (
            <FormItem className="w-full">
              <FormLabel>
                Cast URL</FormLabel>
              <FormControl>
                <Input className="w-full" placeholder="https://warpcast.com/dwr.eth/0x42979bb9" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="address"
          render={({ field }) => (
            <FormItem className="w-full">
              <FormLabel>Wallet Address</FormLabel>
              <FormControl>
                <Input className="w-full" placeholder="dwr.eth" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        { isLoading ? ButtonLoading() : 
        <Button type="submit">Mint Cast</Button>
        }
        <Toaster />
      </form>
    </Form>
  )
}

CastForm.tsx

This will give us our foundational form inputs and an action we can consume once the form is submitted. All that’s left to do is make an API endpoint and call it. To do that, we’ll make a new directory under the /app folder called api, then another inside of that called mint, and finally, we will make a route.ts file (I don’t make the rules). So, the full path would be /app/api/mint/route.ts, and we’ll put in the following code:

import { NextResponse as res } from "next/server";

export const maxDuration = 300;

export async function POST(request: Request) {
  try {
    const data = await request.json()

    if (!data.castUrl) {
      return res.json({
        status: 400,
        message: "Please provide a cast URL"
      });
    }
    if (!data.address) {
      return res.json({
      status: 400,
      message: "Please provide an address to mint the cast to"
      });
    }

    const mintCast = await fetch(
      `https://stevedylandev-pocketCastApi.web.val.run/mint?cast=${data.castUrl}<address=${data.address}`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.VALTOWN_API_KEY}`,
          contentType: "application/json",
          accept: "application/json",
        },
      },
    );

    const mintCastStatus = await mintCast.json();

    if (mintCastStatus.status !== "success") {
      return res.json({
        status: 500,
        message: `Problem minting cast: ${mintCastStatus}`
      });
    }

    return res.json({ mintCastStatus });
  } catch (error) {
    console.log(error);
    return res.json("Server error");
  }
}

/app/api/mint/route.ts

Keep in mind, if you are doing this yourself, your val route might be different, so be sure to replace that. In this API endpoint, we check to make sure we’re receiving a castUrl and an address in the body of the request. Then we simply pass those in as query parameters to our Val.town API call, using our val API key. This allows us to keep our val private and access it securely. You can make a key here and then set the visibility of the val to “private,” like so:

After we make our API call, we can just send the data back to our front end and handle it there.

'use client'

import * as z from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { Button } from "@/components/ui/button"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/use-toast"
import { Toaster } from "@/components/ui/toaster"
import { Loader2 } from "lucide-react"
import { useState } from "react"
import { ToastAction } from "@/components/ui/toast"
import Link from "next/link"


const formSchema = z.object({
  castUrl: z.string().url(),
  address: z.string()
})

export function ButtonLoading() {
  return (
    <Button disabled>
      <Loader2 className="mr-2 h-4 w-4 animate-spin" />
      Minting cast on Base...
    </Button>
  )
}

export default function CastForm() {

  const [isLoading, setIsLoading] = useState(false)

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
  })

  async function onSubmit(values: z.infer<typeof formSchema>) {
    try {
      setIsLoading(true)
      const res = await fetch("/api/mint", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(values),
      })
      const json = await res.json()
      console.log(json)

      if (res.status === 400) {
        toast({
          variant: "destructive",
          title: "Ooops!",
          description: `${json.message}`,
        })
        return
      }
      toast({
        title: "Success!",
        description: "Cast Minted",
        action: (
          <Link href={`https://opensea.io/assets/base/${json.mintCastStatus.contractAddress}/${json.mintCastStatus.tokenId}`}>
            <ToastAction altText="View NFT">View NFT</ToastAction>
          </Link>
        )
      })
      setIsLoading(false)
    } catch (error) {
      toast({
        variant: "destructive",
        title: "Ooops!",
        description: "Something went wrong :/",
      })
      console.log(error)
      setIsLoading(false)
    }
  }
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full gap-4 w-80 lg:w-96">
        <FormField
          control={form.control}
          name="castUrl"
          render={({ field }) => (
            <FormItem className="w-full">
              <FormLabel>
                Cast URL</FormLabel>
              <FormControl>
                <Input className="w-full" placeholder="https://warpcast.com/dwr.eth/0x42979bb9" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="address"
          render={({ field }) => (
            <FormItem className="w-full">
              <FormLabel>Wallet Address</FormLabel>
              <FormControl>
                <Input className="w-full" placeholder="dwr.eth" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        { isLoading ? ButtonLoading() : 
        <Button type="submit">Mint Cast</Button>
        }
        <Toaster />
      </form>
    </Form>
  )
}

CastForm.tsx

Back in our CastForm component, we added our API call to the submit function, as well as added some nice toast pop-ups when minting is complete with a link to the NFT.

In the end, we get an end-to-end app that allows users to mint their favorite casts from Farcaster, with no gas and no fees! You can really do a lot more with this, and perhaps move most of the API from Val.town to the Next.js app or a separate server, but it’s a great example of starting small and iterating your design and architecture gradually. You can check out the repo for the code here as well as the Vals here if you want to take a closer look.

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.