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>
);
}
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>
);
}
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}`
});
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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);
}
}
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>
))}
</>
);
}
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!