Back to blog

How to Build a Lite Client with the Pinata Farcaster API

How to Build a Lite Client with the Pinata Farcaster API

Steve

Over the past few months, we’ve written a lot of content on building Farcaster apps, but nothing has evolved more quickly than Farcaster Clients. In our post on How to Build a Farcaster Client, we walked you through how you could go through the whole flow using just the Hub API and manually handling signers, but since then Pinata has made so many aspects of client building easy. That’s how it should be; things like handling auth, sending casts, and building feeds should be easy so that you can focus on what makes your client shine. In this tutorial, we’ll show you the basics of building a lite client, using the latest and greatest from Pinata!

Setup

As we go through the pieces of building a client, we’ll show you the high level of each piece and how you may want to implement it into your stack or UI components. Additionally you will need the following:

Pinata Account

The first thing you’ll need is a Pinata account, which you can start for free by signing up here. If you want to use managed signers, then you will need a paid plan (hit me up if you want a coupon 😉). Otherwise, you can try handling signers manually with this post. But, trust me, you’ll want to see how much easier it is in this post. Once you create your account, all you need to do is create an API key with these instructions. That’s it!

Farcaster Account

If you are going to be creating signers and allowing users to send casts through your app, then you will likely want a Farcaster Account just for your app, similar to how Photocaster.xyz has its own account at @photocast. With that Farcaster account, you will need both the FID for the account, and the mnemonic phrase that will be used for signing keys that will be assigned to users. Again, all of these are only required if you are making a client that sends casts. If you are doing a read-only client, then we’d love to see it in /pinata!

Stack

For this guide, we’ll be using Next.js with the Pinata FDK, but most of these principles can be migrated over to other stacks in typescript. If you want to work in another language, then we’ve got you covered there too, as everything done with the FDK can be done through the Pinata Farcaster API. If you do follow along in this Next.js tutorial, our .env.local file will look something like this:

# The JWT provided when creating a Pinata API key
PINATA_JWT=
# The mnemonic phrase for your Farcaster App account, e.g. "taco salsa burgers fries..."
DEVELOPER_MNEMONIC=""
# The FID for your Farcaster App account
DEVELOPER_FID=

With all of that ready to go, let’s start with getting users signed in.

Farcaster Auth

We’ve covered how to add Farcaster Auth to your Next.js App already, but we’ll do a short recap with some bonus implementations. When users sign in, there will be two steps. One will be through Auth-Kit by Farcaster, and the other will be through Pinata’s Farcaster Auth. The first sign in doesn’t grant write access to your app, but it does provide a signed message that can be used to verify the person is who they say they are on an FID level. With that information, we can either create a new signer for the user, or fetch one that has already been made.

To start, we’ll make a component to wrap other pieces of our sign in flow.

"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import "@farcaster/auth-kit/styles.css";
import { AuthKitProvider } from "@farcaster/auth-kit";
import { SignIn } from "@/components/sign-in";

const config = {
  rpcUrl: "https://mainnet.optimism.io",
  domain: "farcaster-lazy-client.vercel.app",
  siweUri: "https://farcaster-lazy-client.vercel.app/api/retrieveSigner",
};

export function Auth() {
  const [open, setOpen] = useState(false);

  return (
    <AuthKitProvider config={config}>
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogTrigger asChild>
          <Button className="sm:w-[500px] w-full mt-4" variant="outline">
            +
          </Button>
        </DialogTrigger>
        <DialogContent className="sm:max-w-[425px] max-w-[375px]">
          <SignIn />
        </DialogContent>
      </Dialog>
    </AuthKitProvider>
  );
}

components/auth.tsx

Here we setup our AuthKitProvider with a config using our info, such as the rpcUrl, domain, and siweUri. Then, using some UI components from shadcn/ui, we’ll create a popup modal with a SignIn component where all the magic happens.

"use client";

import { QRCode } from "react-qrcode-logo";
import { Button } from "./ui/button";
import { SignInButton } from "@farcaster/auth-kit";
import { CastForm } from "@/components/cast-form";
import { useEffect, useState } from "react";
import Link from "next/link";

export function SignIn() {
  const [deepLink, setDeepLink]: any = useState();
  const [openQR, setOpenQR] = useState(false);
  const [fid, setFid]: any = useState();
  const [signerId, setSignerId]: any = useState();

  async function createSigner() {
    try {
      const signerReq = await fetch(`/api/signer`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
      });
      const signerRes = await signerReq.json();
      setDeepLink(signerRes.deep_link_url);
      setOpenQR(true);

      const pollReq = await fetch(`/api/poll?token=${signerRes.token}`);
      const pollRes = await pollReq.json();
      const pollStartTime = Date.now();
      while (pollRes.state != "completed") {
        if (Date.now() - pollStartTime > 120000) {
          console.log("Polling timeout reached");
          alert("Request timed out");
          setOpenQR(false);
          break;
        }
        const pollReq = await fetch(`/api/poll?token=${signerRes.token}`);
        const pollRes = await pollReq.json();
        if (pollRes.state === "completed") {
          setDeepLink(null);
          setOpenQR(false);
          setSignerId(signerRes.signer_id);
          localStorage.setItem("signer_id", signerRes.signer_id);
          return pollRes;
        }
        await new Promise((resolve) => setTimeout(resolve, 2000));
      }
    } catch (error) {
      console.log(error);
    }
  }

  async function checkStorage(signature?: any, message?: any, nonce?: any) {
    try {
      if (typeof window != "undefined") {
        const signer = localStorage.getItem("signer_id");
        if (signer != null) {
          setSignerId(signer);
        } else {
          const data = JSON.stringify({
            message: message,
            signature: signature,
            nonce: nonce,
          });
          const signerReq = await fetch(`/api/retrieveSigner`, {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: data,
          });
          const signerRes = await signerReq.json();
          if (signerRes.signers && signerRes.signers.length > 0) {
            console.log("signer found and set");
            setSignerId(signerRes.signers[0].signer_uuid);
            localStorage.setItem("signer_id", signerRes.signers[0].signer_uuid);
          } else {
            console.log("no signer found");
            return;
          }
        }
      }
    } catch (error) {
      console.log(error);
    }
  }

  async function handleSignInSuccess({ fid, signature, message, nonce } : any){
    setFid(fid)
    checkStorage(signature, message, nonce)
  }

  useEffect(() => {
    checkStorage();
  }, []);

  return (
    <div className="mx-auto">
      {!signerId && fid && (
        <div className="flex flex-col gap-3">
          <Button onClick={createSigner}>Create Signer</Button>
          {openQR && (
            <div className="flex flex-col gap-3">
              <QRCode
                value={deepLink}
                size={250}
                logoImage="https://dweb.mypinata.cloud/ipfs/QmVLwvmGehsrNEvhcCnnsw5RQNseohgEkFNN1848zNzdng"
                logoWidth={50}
                logoHeight={50}
                logoPadding={5}
                logoPaddingStyle="square"
                qrStyle="dots"
                eyeRadius={15}
              />
              <Link className="w-full" href={deepLink}>
                <Button className="w-full">Mobile Link</Button>
              </Link>
            </div>
          )}
        </div>
      )}

      {signerId && <CastForm signerId={signerId} />}

      {!fid && !signerId && (
        <SignInButton
          onSuccess={handleSignInSuccess}
        />
      )}
    </div>
  );
}

components/sign-in.tsx

Now, there is a fair bit going on here, and it will probably be the biggest component in your app. But let’s break it down piece by piece. The app will do a few things. First, if there is no fid or signerId set, the user is going to get the SignInButton from auth-kit that will get their FID for us. Once we hasendinve the FID, we’ll set it and checkStorage. This function will first check if the user has any previous local storage keys we might have set previously, and if not, we’ll make an API request to /api/retrieveSigner with info provided from the onSuccess of the auth kit sign in, including the fid, signature, message, and nonce. These are all crucial to authenticate our Farcaster user before giving them access to a previously issued signer key. Before we look at some of our API routes, we’ll make a quick config file to make it easier to import and re-use the Pinata FDK.

import { PinataFDK } from "pinata-fdk";

export const fdk = new PinataFDK({
  pinata_jwt: process.env.PINATA_JWT as string,
  pinata_gateway: "",
  app_fid: `${process.env.DEVELOPER_FID}`,
  app_mnemonic: `${process.env.DEVELOPER_MNEMONIC}`
});

config/fdk.ts

With that done, let’s look at /api/retrieveSigner to see how it’s being used there.

import { NextResponse, NextRequest } from "next/server";
import { createAppClient, viemConnector } from "@farcaster/auth-client";
import { fdk } from "@/config/fdk"

const appClient = createAppClient({
  relay: "https://relay.farcaster.xyz",
  ethereum: viemConnector()
});


export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    console.log(body.message)
    const { success, fid, error, isError } = await appClient.verifySignInMessage({
      nonce: body.nonce,
      domain: "farcaster-lazy-client.vercel.app",
      message: body.message,
      signature: body.signature,
    });

    if(isError){
      console.log(error)
      return NextResponse.json(error);
    }

    if (success) {
      const res = await fdk.getSigners(fid);
      console.log(res);
      return NextResponse.json(res);
    } else {
      return NextResponse.json("Error verifying signature");
    }
  } catch (error) {
    console.log(error);
    return NextResponse.json(error);
  }
}

app/api/retrieveSigner/route.ts

In this API route, we use auth-client from Farcaster to create a new appClient. This can be used to verifySignInMessage with the info passed from the client. If it passes authentication, then we can fetch our previously issued signers from fdk.getSigners(fid) using the FID provided by the signature verification. This is such a great combo because it provides a secure way to let users sign in, get write access, and only have to issue one signer key for your app. Now that we have the key, we can send it back to the client, specifically in the checkStorage function.

async function checkStorage(signature?: any, message?: any, nonce?: any) {
    try {
      if (typeof window != "undefined") {
        const signer = localStorage.getItem("signer_id");
        if (signer != null) {
          setSignerId(signer);
        } else {
          const data = JSON.stringify({
            message: message,
            signature: signature,
            nonce: nonce,
          });
          const signerReq = await fetch(`/api/retrieveSigner`, {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: data,
          });
          const signerRes = await signerReq.json();
          if (signerRes.signers && signerRes.signers.length > 0) {
            console.log("signer found and set");
            setSignerId(signerRes.signers[0].signer_uuid);
            localStorage.setItem("signer_id", signerRes.signers[0].signer_uuid);
          } else {
            console.log("no signer found");
            return;
          }
        }
      }
    } catch (error) {
      console.log(error);
    }
  }

If we do get a signer, then we can put it in local storage for easier access and create a session to be used by the user. This gives us a full loop which we can use when the app is first loaded to check that local storage and re-use that key!

Now, if we don’t have any signers for a user, we can give them an option to create a user. This is done through the createSigner function.

async function createSigner() {
    try {
      const signerReq = await fetch(`/api/signer`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
      });
      const signerRes = await signerReq.json();
      setDeepLink(signerRes.deep_link_url);
      setOpenQR(true);

      const pollReq = await fetch(`/api/poll?token=${signerRes.token}`);
      const pollRes = await pollReq.json();
      const pollStartTime = Date.now();
      while (pollRes.state != "completed") {
        if (Date.now() - pollStartTime > 120000) {
          console.log("Polling timeout reached");
          alert("Request timed out");
          setOpenQR(false);
          break;
        }
        const pollReq = await fetch(`/api/poll?token=${signerRes.token}`);
        const pollRes = await pollReq.json();
        if (pollRes.state === "completed") {
          setDeepLink(null);
          setOpenQR(false);
          setSignerId(signerRes.signer_id);
          localStorage.setItem("signer_id", signerRes.signer_id);
          return pollRes;
        }
        await new Promise((resolve) => setTimeout(resolve, 2000));
      }
    } catch (error) {
      console.log(error);
    }
  }

First, it’s going to make a request to /api/signer to make a new signer key via Farcaster Auth, and the code there is very simple.

import { NextResponse } from "next/server";
import { fdk } from "@/config/fdk";

export async function POST() {
  try {
    const res = await fdk.createSponsoredSigner();
    return NextResponse.json(res);
  } catch (error) {
    console.log(error);
    return NextResponse.json(error);
  }
}

app/api/signer/route.ts

Once it sends a response, it’s going to give us three important pieces:

  • A signer_id that we will use later as the key to access writes for our users
  • A deep_link_url that will be used for the user to approve the key
  • A token that will act as the polling token to see if/when the user approves the key.

With the QR code set, and provided to the user, we can start hitting /api/poll with our token, which again the FDK makes this a breeze.

import { NextResponse, NextRequest } from "next/server";
import { fdk } from "@/config/fdk";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  try {
    const token: any = searchParams.get("token");
    const res = await fdk.pollSigner(token);
    console.log(res);
    return NextResponse.json(res);
  } catch (error) {
    console.log(error);
    return NextResponse.json(error);
  }
}

app/api/poll/route.ts

The results of this call will tell us the status of the poll, with which we’ll begin a temporary loop for 120 seconds, checking every 2 seconds if the user has scanned the QR code and approved the signer in Warpcast. If they do approve, then we’ll set the signerId state with the previously derived signer_id and we’ll write it to local storage. If not approved, we’ll show an error message that they need to try again.

That completes our auth, providing a secure and reliable way for Farcaster apps to create and fetch existing signers. Again, the beauty here is that Pinata is managing those signers. The developer has the choice to use sponsored signers so that the end user doesn’t have to pay warps, and with the ability to authenticate a previous user and fetch their signer ID, a user really only needs one. No need to worry about keys getting lost in local storage or risking handling private keys in your own database. With that said, what good is a signer if you can’t send a cast? Let’s do that next.

Sending Casts

With our signerId ready to rock and roll, let's take a quick peer at the submit function in our form component.

async function onSubmit(values: z.infer<typeof formSchema>) {
    try {
      setLoading(true)
      const data = JSON.stringify({
        signerId: signerId,
        castMessage: values.cast,
      });
      const submitMessage = await fetch("/api/cast", {
        method: "POST",
        headers: {
          contentType: "application/json",
        },
        body: data,
      });
      const messageJson = await submitMessage.json();
      console.log(messageJson);
      setLoading(false);
      if(!submitMessage.ok){
        alert("Error sending cast");
        return
      }
      setCastComplete(true);
    } catch (error) {
      console.log(error);
      alert("Error sending cast");
      setLoading(false);
    }
  }

components/cast-form.tsx

Sending a cast with the FDK really doesn’t get much easier, as we simply build what we want our cast to look like and send it over an API route. In this example, we’re just doing text posts, but depending how you want to build your casts with quotes, mentions, or media, you can absolutely do that here too. For now, we’ll just use our signerId and our castMessage from our form input, then send it to /api/cast.

import { NextResponse, NextRequest } from "next/server";
import { fdk } from "@/config/fdk"

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const message = body.castMessage;
    console.log(body)

    const res = await fdk.sendCast({
      castAddBody: {
        text: message,
        parentUrl: "https://warpcast.com/~/channel/pinata"
      },
      signerId: body.signerId,
    });
    if (!res.hash) {
      return NextResponse.json(
        { Error: "Failed to send cast" },
        { status: 500 },
      );
    } else {
      const hash = res.hash
      return NextResponse.json({ hash }, { status: 200 });
    }
  } catch (error) {
    console.log(error);
    return NextResponse.json(error);
  }
}

app/api/cast/route.ts

The API route is just as simple, where we parse the body and use fdk.sendCast with the structure of our cast and the signerId. Here, we just default to sending the cast to the /pinata channel, but we could make this something the user specifies if we wanted to. Then we just check if a hash is returned, and if so, then we send a success back to the client. Piece of cake! Now that the cast is sent, we want to be able to see it, so let’s build a feed.

Building a Feed

The Farcaster API makes it seamless to build a feed of posts, allowing speed and flexibility. Let’s take a look at this example feed component.

import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Embed } from "@/components/embed";

export const dynamic = 'force-dynamic'

async function cronFeed(channel: any, pageSize: any) {
  try {
    const result = await fetch(
      `https://api.pinata.cloud/v3/farcaster/casts?channel=${channel}&pageSize=${pageSize}`,
      {
        next: { revalidate: 60 },
        method: "GET",
        headers: {
          Authorization: `Bearer ${process.env.PINATA_JWT}`,
        },
      },
    );
    if (!result.ok) {
      throw new Error("failed to fetch data");
    }
    const resultData = await result.json();
    return resultData;
  } catch (error) {
    console.log(error);
    return error;
  }
}

export async function Feed() {
  const feed = await cronFeed("diet-coke", 50);

  return (
    <>
      {feed.casts.map((cast: any) => (
        <div
          className="flex gap-4 sm:w-[500px] w-[350px] flex-row items-start"
          key={cast.hash}
        >
          <Avatar>
            <AvatarImage src={cast.author.pfp_url} />
            <AvatarFallback>CN</AvatarFallback>
          </Avatar>
          <div className="flex flex-col items-start w-full">
            <div className="flex gap-2">
              <p className="font-bold">{cast.author.display_name}</p>
              <p className="text-gray-600">@{cast.author.username}</p>
            </div>
            <p className="pb-2">{cast.text.replace(/https?:\/\/\S+/i, '')}</p>
            {cast.embeds &&
              cast.embeds.length > 0 ? (
              <Embed embedObject={cast.embeds[0]} />
            ) : null}
          </div>
        </div>
      ))}
      </>
  );
}

components/feed.tsx

Since we’re using Next.js with App router, we’ve made this a server component to fetch the feed, then display it. Inside cronFeed we have an API call that gets casts from a specified channel, and how many posts we want returned. Thanks to our query params &topLevel=true&reverse=true we will only get the latest posts and the top level posts (instead of posts and the replies). Then, we just map over those casts with some nice styles and a special Embed component that can handle links, images, or other media.

Wrapping Up

In the end, we have a client for the “lazy” developer, but what we really mean is the “speedy” developer. All of these tools are designed to handle the hardest parts of building a client and making them easy so that you can excel at building the special aspects of your client, whether that be a media focused client or a channel specific one. To see this client in action, visit the app here, and feel free to browse the code for it here. Our goal is to make Farcaster easy, but also scale as it grows into a flourishing, sufficiently decentralized, social media.

Happy Casting!

Subscribe to paid plan image

Share this post:

Stay up to date

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