Blog home

How to Build a Farcaster Client

Steve

Published on

34 min read

How to Build a Farcaster Client

Build your own decentralized photo-sharing app with Farcaster and IPFS.

Farcaster has taken the Web3 scene by storm as Frames introduced a whole new wave of users seeing the potential in this protocol. Unlike other Web2 socials like X or Instagram, Farcaster is a sufficiently decentralized and permissionless protocol. That means there’s not one person or entity controlling the network, and anyone can view or build on top of it. Many people get Warpcast, the primary client for Farcaster, confused with the protocol itself. The truth is that with Farcaster Hubs, developers can build their own unique interfaces and experiences for those who want to view or post on Farcaster. Some other popular clients may include Supercast.xyz or tiles, so if I post on Supercast the post will show up there but it will also show up on Warpcast or any other client connected to a Hub. This opens up the possibility for specialized clients to fulfill particular needs of consumers, and build out an ecosystem around decentralized social media.

One such client that I built was photocaster.xyz. One day when I posted a string of photos I took into the /photography channel, were heavily optimized by Warpcast, and for good reason. They were 20MB photos straight out of a camera, and for reference, most internet photos have a goal of maybe sub 500kb. In order for clients like Warpcast to keep the app smooth, optimizing these images is a must.

But it did prompt a thought: what if we built a photo client using IPFS?

IPFS is the perfect candidate for a project like this because it perfectly preserves the content you upload. A full-res 20MB photo living on IPFS will be just fine on the network, and when we need to render it in a client application, we can use the built-in image optimizations from the Pinata Dedicated Gateways. The images people upload can be pinned, archived, and shared via IPFS for high-quality downloads, but still rendered in a way that doesn’t hurt other Farcaster clients. It really is the perfect marriage of two distributed networks working in tandem to provide better experiences for users.

In this blog post, we’ll go into details of what core elements are needed for a Farcaster client and the building blocks so you can do it yourself. This won’t be an end-to-end tutorial as there are lots of pieces of photocaster.xyz that will end up being your own personal decisions, like how to theme or design the layout, or what features you want to include. Instead, we’ll show you the basics on:

  • Creating a feed of posts
  • Signing in with Warpcast
  • Uploading content to IPFS to be used with Farcaster
  • Submitting Casts for users

At any point, you can review this open repository to see how Photocaster is built and reference any of the pieces we mention here. I will give a word of warning that this is not a beginner coding project and falls more in the realm of intermediate skill level. Knowledge of APIs and working with client-side tech like Next.js is a must.

Setup

To build a Farcaster client you’ll definitely need a few tools, and we’ll go over the ones we used for Photocaster.

Pinata Account

To handle our high-res image uploads on IPFS we’ll use Pinata, and you can start with a free account since it has all the features of the paid plans. The two things we’ll utilize are an API key for uploads and our Dedicated Gateway that's included for rendering the images and performing optimizations on the fly. We’ll go into more detail later on how we used these features together.

Pinata Hub

If you want to read and post data to Farcaster, you’ll need a Farcaster Hub. These are like nodes that help run data between the on-chain contracts as well as store data off-chain in a peer-to-peer network. Thankfully we can use the Pinata Hub, a free-to-use Hub that’s fast and reliable. If you want to see all the kinds of data you can fetch with the hub, check out our API docs with all the available methods!

Farcaster Core

One of the Javascript packages we’re going to use is Farcaster Core. This helps with bundling messages and handling signers to use in conjunction with the Hub API. Farcaster also offers a hub-nodejs SDK which abstracts just a little bit, but in my experience caused more problems than benefits. For what we’re doing the Core library is perfect and keeps our code clean.

Next.js & shadcn/ui

You could build a client with any stack thanks to the Hub API, but to keep things easy we’ll use a Next.js project using all the v.14 defaults including app router, tailwind, and typescript. Along with Next, we’ll utilize the shadcn/ui component library to handle all our buttons and drop-downs, making it quick and easy to build a beautiful app.

Building a Feed

As with many other social media platforms, the first thing you want to see when you sign in is a feed of content. Farcaster has this built-in with something called Channels, or the technical term parent_url's. Channels are very similar to subreddits where there can still be one big feed but there are other smaller feeds that a post can be narrowed down to. When you send a cast through a Hub API, there's a CastAddBody with all the content of the post. It has things like the text of the post, embeds like images, and parent_url to what channel the content should show up in. In reverse, the Hub API also lets us fetch all the casts for a specific channel. That's the core of how we’ll fetch the data for our feed and build it out.

Let’s take a look at the core functions we used to fetch a feed in utils/fc.ts.

export async function cronFeed(channel: any, nextPage: any) {
  try {
    const result = await fetch(
      `https://hub.pinata.cloud/v1/castsByParent?url=${channel}&pageSize=20&reverse=true&pageToken=${nextPage}`,
    );
    const resultData = await result.json();
    const pageToken = resultData.nextPageToken;
    const casts = resultData.messages;
    const simplifiedCasts = await Promise.all(
      casts.map(async (cast: any) => {
        const fname = await getFnameFromFid(cast.data.fid);
        return {
          fid: fname,
          castAddBody: cast.data.castAddBody,
          pageToken: pageToken,
        };
      }),
    );
    return simplifiedCasts;
  } catch (error) {
    console.log(error);
    return error;
  }
}

export async function getFnameFromFid(fid: any): Promise<string> {
  const result = await fetch(
    `https://hub.pinata.cloud/v1/userDataByFid?fid=${fid}&user_data_type=USER_DATA_TYPE_USERNAME`,
  );
  const resultData = await result.json();
  const fname = resultData?.data?.userDataBody?.value || fid;
  return fname;
}

utils/fc.ts

First, we have cronFeed which by using the Pinata Hub API we can fetch castsByParent URL with the specified channel as a parameter, along with some helpful queries to limit the number of results and to fetch it in reverse to create a chronological feed. There’s also an extra query for pageToken which will let us paginate through more casts. Once we get the results of those casts, we’ll create a simplified array of the casts and the information we need such as the FID, the content of the cast in `castAddBody`, and the pageToken. You’ll also notice a second function we call in tandem to help get the usernames for an FID, which returns a username if it exists.

To fetch the data for this feed we’ll use an API route inside Next.js under app/api/feed/route.ts.

import { NextRequest, NextResponse } from "next/server";
import { cronFeed } from "@/utils/fc";

export async function POST(req: NextRequest, res: NextResponse) {
  try {
    const body = await req.json();
    const casts = await cronFeed(body.channel, body.nextPage);
    return NextResponse.json(casts);
  } catch (error) {
    console.log(error);
    return NextResponse.json({ error: error });
  }
}

app/api/feed/route.ts

With our data ready to go, it's just a matter of making some components to fetch and render it. At the lower level, we’ll use a combination of components/feed-card.tsx and components/dynamic-image.tsx.

import { Dialog } from "./ui/dialog";
import { DynamicImage } from "./dynamic-image";

export default function FeedCard({ image, author, text }: {image: string; author: string; text: string}) {
  return (
    <Dialog>
      <DynamicImage image={image} author={author} text={text} />
    </Dialog>
  );
}

components/feed-card.tsx

import { useState } from "react";
import { Expand } from "lucide-react";
import { DialogContent, DialogTrigger } from "@/components/ui/dialog";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

export function DynamicImage({ image, author, text }: {image: string; author: string, text: string }) {
  const [imageUrl, setImageUrl] = useState(image);
  const [error, setError] = useState(false);

  const handleImageError = () => {
    setError(true);
    setImageUrl("/placeholder.webp");
  };

  if (error) {
    return null;
  }

  if (image.includes("/ipfs")) {
    let rawUrl = new URL(image);
    rawUrl.search = "";

    return (
      <div>
        <div className="relative flex flex-col gap-2">
          <DialogTrigger>
            <Expand className="absolute opacity-60 bottom-2 right-2" />
          </DialogTrigger>
          <TooltipProvider>
            <Tooltip>
              <TooltipTrigger asChild>
                <img
                  src={imageUrl}
                  className="object-cover sm:max-w-[500px]"
                  alt="image"
                  onError={handleImageError}
                />
              </TooltipTrigger>
              <TooltipContent>
                <p className="sm:max-w-[500px] max-w-screen">{text}</p>
              </TooltipContent>
            </Tooltip>
          </TooltipProvider>
        </div>
        <p className="font-bold text-gray-500">@{author}</p>
        <DialogContent className="sm:max-h-screen sm:max-w-screen max-w-screen max-h-screen overflow-scroll">
          <img
            src={rawUrl.toString()}
            className="object-scale-down m-auto max-w-screen max-h-screen"
            alt="image"
            onError={handleImageError}
          />
        </DialogContent>
      </div>
    );
  } else {
    return (
      <div className="flex flex-col gap-2">
        <TooltipProvider>
          <Tooltip>
            <TooltipTrigger asChild>
              <img
                src={imageUrl}
                className="object-cover sm:max-w-[500px]"
                alt="image"
                onError={handleImageError}
              />
            </TooltipTrigger>
            <TooltipContent>
              <p className="sm:max-w-[500px] max-w-screen">{text}</p>
            </TooltipContent>
          </Tooltip>
        </TooltipProvider>

        <p className="font-bold text-gray-500">@{author}</p>
      </div>
    );
  }
}

components/dynamic-image.tsx

In these, we pass down the core information of our feed items, such as the image, author, and text. Something special we enable here is the ability to upsize an image. If the URL contains /ipfs in its path then we can render it through our gateway with no image optimizations, returning the full image. Otherwise, we can just return a regular image with the username as well as the caption/text available on hover via a tooltip component.

Zooming out this feed-card is using in the compontents/feed.tsx file, so let’s look at that in more depth.

"use client";

import React, { useState, useEffect } from "react";
import FeedCard from "./feed-card";
import { Loader2 } from "lucide-react";
import { Button } from "./ui/button";

export default function Feed({ channel }: any) {
  const [feed, setFeed]: any = useState([]);
  const [loading, setLoading] = useState(false);
  const [loadingMore, setLoadingMore] = useState(false)
  const [nextPageToken, setNextPageToken] = useState("");

  async function fetchData(nextPage: any, initialLoad: boolean) {
    try {
      if (initialLoad) {
        setLoading(true);
      } else {
        setLoadingMore(true)
      }
      const data = JSON.stringify({
        channel: channel,
        nextPage: nextPage,
      });
      const feedData = await fetch("/api/feed", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: data,
      });
      const feed = await feedData.json();

      const isEmbedValid = (item: any) => item.castAddBody.embeds[0]?.url;
      const isUrlValid = (item: any) =>
        !item.castAddBody.embeds[0]?.url.includes("stream.warpcast.com");

      const filteredFeed = feed.filter(
        (item: any) =>
          isEmbedValid(item) && isUrlValid(item),
      );

      setFeed((prevFeed: any) => [...prevFeed, ...filteredFeed]);
      setNextPageToken(feed[0].pageToken);
      setLoading(false);
      setLoadingMore(false)
    } catch (error) {
      console.log(error);
      setLoading(false);
      setLoadingMore(false)
    }
  }

  function refetchData() {
    fetchData(nextPageToken, false);
  }

  useEffect(() => {
    setFeed([]); // Clear the feed state
    fetchData("", true);
  }, [channel]);

  return (
    <div className="mt-4 flex min-h-screen flex-col items-center justify-start">
      {loading ? (
        <Loader2 className="h-16 w-16 animate-spin" />
      ) : (
        <div className="flex flex-col items-center justify-start gap-12 mb-6">
          {feed ? (
            feed.map((item: any, index: any) => (
              <FeedCard
                key={index}
                image={item.castAddBody.embeds[0].url}
                author={item.fid || "anon"}
                text={item.castAddBody.text}
              />
            ))
          ) : (
            <h1>Failed to fetch Posts</h1>
          )}
          {loadingMore ? (
            <Button disabled>
              <Loader2 className="h-4 w-4 animate-spin" />
            </Button>
          ) : (
            <Button variant="secondary" onClick={refetchData}>
              More
            </Button>
          )}
        </div>
      )}
    </div>
  );
}

It’s a little lengthy, but let me break down what’s going on. First, we’re passing down the designated channel through the component props so we can have some change in the channel and thus change the feed as well. Based on the channel we make an API payload of the channel and page token if applicable. This returns the data from our cronFeed that we can turn into a usable array of data to map over. Since this is an image-focused client we’ll filter out anything that is not an image, as well as possible videos designated by stream.warpcast.com. We’ll feed the state in a way that if we want to, we can press a button to fetch the next page of data using the latest returned page token. In a more complex app, you could use something like useInfiniteQuery but this seemed to work fine here.

Now I mentioned changing the channel, so let’s go up a bit higher to see where that’s happening. For this project, we used a simple JSON file with a list of channels we wanted to include.

[
  {
   "name": "/photography",
    "url": "chain://eip155:7777777/erc721:0x36ef4ed7a949ee87d5d2983f634ae87e304a9ea2"
  },
  {
    "name": "/travel",
    "url": "chain://eip155:7777777/erc721:0x917ef0a90d63030e6aa37d51d7e6ece440ace537"
  },
  {
    "name": "/streetphotos",
    "url": "https://warpcast.com/~/channel/streetphotos"
  },
  {
    "name": "/film-photography",
    "url": "https://warpcast.com/~/channel/film-photography"
  },
  {
    "name": "/mountains",
    "url": "https://warpcast.com/~/channel/mountains"
  },
  {
    "name": "/itookaphoto",
    "url": "https://warpcast.com/~/channel/itookaphoto"
  },
  {
    "name": "/art",
    "url": "chain://eip155:1/erc721:0x1538c5ddbb073638b7cd1ae41ec2d9f9a4c24a7e"
  },
  {
    "name": "/food",
    "url": "chain://eip155:1/erc721:0xec0ba367a6edf483a252c3b093f012b9b1da8b3f"
  },
  {
    "name": "/nature",
    "url": "chain://eip155:7777777/erc721:0xf6a7d848603aff875e4f35025e5c568679ccc17c"
  }
]

Now you could automated this and use a Warpcast API endpoint to fetch all the channels, but since this is a photo specific client we wanted to filter a bit. We have the initial channel state set at the top of the app inside app/page.tsx and then pass in a setChannel hook down into components/nav.tsx, and inside of the nav we’ll pass it into componets/channel-change.tsx.

"use client";

import * as React from "react";
import { Newspaper } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import channels from "@/utils/channels.json"
import type { Channel } from "@/utils/types/channels";

export function ChannelSwitch({ setChannel }: any) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Newspaper className="absolute h-[1.2rem] w-[1.2rem] text-current transition-all" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        {channels.map((channel: Channel) => (
          <DropdownMenuItem
            key={channel.url}
          onClick={() =>
            setChannel(
              channel.url,
            )
          }
        >
            {channel.name}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

components/channel-change.tsx

Nothing crazy happening here, just taking in our list of channels from our JSON file, mapping over them as selections, and then based on the user’s choice call setChannel which will update the feed. That’s the majority of our feed functionality!

Sign-in with Warpcast

Singing into another Farcaster client is still a bit of a hassle at the moment, but huge shout out to David Furlong for his minimal login method used in @frames.js  that we used here. The reason the flow is a bit bumpy is because Hubs require an ed25519 signer created on behalf of the user to sign and create casts. Doing this onchain costs gas, which is not a great user experience. However, there is a small level of abstraction available thanks to Warpcast. Warpcast offers an app native currency called “Warps” which you can buy with a credit card. These can be used to cover the costs to sign into other apps, and they have an API endpoint you can call with the user info that will let them approve a sign-in and create a signer for your app to use. This signing key is kept in the user’s browser cookies so it is a far less than ideal flow because if the user switches to another browser or has incognito mode on, they would need to sign in again and pay more warps. It’s still early, so there are certainly opportunities to abstract this flow more for devs and users.

The core of our sign-in flow begins with utils/use-farcaster-identity.tsx, again credit goes to David for this one.

"use client";

import { useEffect, useState } from "react";
import { FarcasterUser } from "./types/farcaster-user";
import { convertKeypairToHex, createKeypair } from "./crypto";
export const LOCAL_STORAGE_KEYS = {
  FARCASTER_USER: "farcasterUser",
};

interface SignedKeyRequest {
  deeplinkUrl: string;
  isSponsored: boolean;
  key: string;
  requestFid: number;
  state: string;
  token: string;
  userFid: number;
  signerUser?: object;
  signerUserMetadata?: object;
}

export function useFarcasterIdentity() {
  const [loading, setLoading] = useState(false);
  const [farcasterUser, setFarcasterUser] = useState<FarcasterUser | null>(
    getSignerFromLocalStorage()
  );

  function getSignerFromLocalStorage() {
    if (typeof window !== "undefined") {
      const storedData = localStorage.getItem(
        LOCAL_STORAGE_KEYS.FARCASTER_USER
      );
      if (storedData) {
        const user: FarcasterUser = JSON.parse(storedData);

        if (user.status === "pending_approval") {
          // Validate that deadline hasn't passed
          if (user.deadline < Math.floor(Date.now() / 1000)) {
            localStorage.removeItem(LOCAL_STORAGE_KEYS.FARCASTER_USER);
            return null;
          }
        }

        return user;
      }
      return null;
    }

    return null;
  }

  useEffect(() => {
    const signer = getSignerFromLocalStorage();
    if (signer) setFarcasterUser(signer);
  }, []);

  function logout() {
    localStorage.setItem(LOCAL_STORAGE_KEYS.FARCASTER_USER, "");
    setFarcasterUser(null);
  }

  useEffect(() => {
    if (farcasterUser && farcasterUser.status === "pending_approval") {
      let intervalId: any;

      const startPolling = () => {
        intervalId = setInterval(async () => {
          try {
            const fcSignerRequestResponse = await fetch(
              `https://api.warpcast.com/v2/signed-key-request?token=${farcasterUser.token}`,
              {
                method: "GET",
                headers: {
                  "Content-Type": "application/json",
                },
              }
            );
            const responseBody = (await fcSignerRequestResponse.json()) as {
              result: { signedKeyRequest: SignedKeyRequest };
            };
            if (responseBody.result.signedKeyRequest.state !== "completed") {
              throw new Error("hasnt succeeded yet");
            }

            const user = {
              ...farcasterUser,
              ...responseBody.result,
              fid: responseBody.result.signedKeyRequest.userFid,
              status: "approved" as const,
            };
            // store the user in local storage
            localStorage.setItem(
              LOCAL_STORAGE_KEYS.FARCASTER_USER,
              JSON.stringify(user)
            );

            setFarcasterUser(user);
            clearInterval(intervalId);
          } catch (error) {
            console.info(error);
          }
        }, 2000);
      };

      const stopPolling = () => {
        clearInterval(intervalId);
      };

      const handleVisibilityChange = () => {
        if (document.hidden) {
          stopPolling();
        } else {
          startPolling();
        }
      };

      document.addEventListener("visibilitychange", handleVisibilityChange);

      // Start the polling when the effect runs.
      startPolling();

      // Cleanup function to remove the event listener and clear interval.
      return () => {
        document.removeEventListener(
          "visibilitychange",
          handleVisibilityChange
        );
        clearInterval(intervalId);
      };
    }
  }, [farcasterUser]);

  async function startFarcasterSignerProcess() {
    setLoading(true);
    await createAndStoreSigner();
    setLoading(false);
  }

  async function createAndStoreSigner() {
    try {
      const keypair = await createKeypair();
      const keypairString = convertKeypairToHex(keypair);
      const authorizationResponse = await fetch(`/api/signer`, {
        method: "POST",
        body: JSON.stringify({
          publicKey: keypairString.publicKey,
        }),
      });
      const authorizationBody: {
        signature: string;
        requestFid: string;
        deadline: number;
        requestSigner: string;
      } = await authorizationResponse.json();
      const { signature, requestFid, deadline } = authorizationBody;
      if (authorizationResponse.status === 200) {
        const {
          result: { signedKeyRequest },
        } = (await (
          await fetch(`https://api.warpcast.com/v2/signed-key-requests`, {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              key: keypairString.publicKey,
              signature,
              requestFid,
              deadline,
            }),
          })
        ).json()) as {
          result: { signedKeyRequest: { token: string; deeplinkUrl: string } };
        };

        const user: FarcasterUser = {
          ...authorizationBody,
          publicKey: keypairString.publicKey,
          deadline: deadline,
          token: signedKeyRequest.token,
          signerApprovalUrl: signedKeyRequest.deeplinkUrl,
          privateKey: keypairString.privateKey,
          status: "pending_approval",
        };
        localStorage.setItem(
          LOCAL_STORAGE_KEYS.FARCASTER_USER,
          JSON.stringify(user)
        );
        setFarcasterUser(user);
      }
    } catch (error) {
      console.error("API Call failed", error);
    }
  }

  return {
    farcasterUser,
    loading,
    startFarcasterSignerProcess,
    logout,
  };
}

utils/use-farcaster-identity.tsx

There’s a lot going on here and I’ll do my best to explain each piece here. First, there is a main function called startFarcasterSignerProcess which is going to check local storage first to see if the browser already has a key stored. If it does, then it returns it to the app. If not, it will start the process of createAndStoreSigner(). That function will make an API call to app/api/signer/route.ts which we’ll look at now:

import { NextRequest, NextResponse } from "next/server";
import { mnemonicToAccount } from "viem/accounts";

if (
  !process.env.FARCASTER_DEVELOPER_MNEMONIC ||
  !process.env.FARCASTER_DEVELOPER_FID
) {
  console.warn(
    "define the FARCASTER_DEVELOPER_MNEMONIC and FARCASTER_DEVELOPER_FID environment variables"
  );
}

const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = {
  name: "Farcaster SignedKeyRequestValidator",
  version: "1",
  chainId: 10,
  verifyingContract: "0x00000000fc700472606ed4fa22623acf62c60553",
} as const;

const SIGNED_KEY_REQUEST_TYPE = [
  { name: "requestFid", type: "uint256" },
  { name: "key", type: "bytes" },
  { name: "deadline", type: "uint256" },
] as const;

export async function POST(req: NextRequest, res: NextResponse) {
  try {
    const { publicKey } = await req.json();

    const appFid = process.env.FARCASTER_DEVELOPER_FID!;
    const account = mnemonicToAccount(
      process.env.FARCASTER_DEVELOPER_MNEMONIC!
    );

    const deadline = Math.floor(Date.now() / 1000) + 86400; // signature is valid for 1 day
    const signature = await account.signTypedData({
      domain: SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN,
      types: {
        SignedKeyRequest: SIGNED_KEY_REQUEST_TYPE,
      },
      primaryType: "SignedKeyRequest",
      message: {
        requestFid: BigInt(appFid),
        key: publicKey,
        deadline: BigInt(deadline),
      },
    });

    return Response.json({
      signature,
      requestFid: parseInt(appFid),
      deadline,
      requestSigner: account.address,
    });
  } catch (err) {
    console.error(err);
    return NextResponse.error();
  }
}

app/api/signer/route.ts

In this API endpoint, we’re interacting with an EIP-712 contract with our FARCASTER_DEVELOPER_MNEMONIC and FARCASTER_DEVELOPER_FID to create a signer key for the user. The mnemonic phrase and FID is for the account of our app, so if you haven’t already you’ll need a Farcaster account for the app you’re building; ours is @photocast. After the interaction with the contract is complete it will send back our signature and requestSigner . Once that data is sent back to our createAndStoreSigner() we use it in our request to Warpcast and trigger the user to pay with warps and create the key. While this is happening our app will be polling the response from Warpcast and store the returned key in the user’s browser cookies.

To engage and start this process for the user, we’ll have a component that triggers the useFarcasterIdentity() hook called components/login-window.tsx:

import QRCode from "qrcode.react";
import { FarcasterUser } from "@/utils/types/farcaster-user";
import { Button } from "./ui/button";
import { Card, CardContent } from "./ui/card";
import { Terms } from "./terms";
import Link from "next/link";

export const LoginWindow = ({
  farcasterUser,
  loading,
  startFarcasterSignerProcess,
  logout,
}: {
  farcasterUser: FarcasterUser | null;
  loading: boolean;
  startFarcasterSignerProcess: () => void;
  logout: () => void;
}) => {
  return (
    <div className="flex flex-col w-full justify-center items-center">
      <div className="m-4 w-full">
        {farcasterUser?.status === "approved" ? (
          farcasterUser.fid ? (
            <>
              <p className="text-center mb-4">FID: {farcasterUser.fid}</p>
              <Button className="w-full" onClick={logout}>
                Logout
              </Button>
            </>
          ) : (
            "Something is wrong..."
          )
        ) : farcasterUser?.status === "pending_approval" ? (
          ""
        ) : (
          <h2 className="text-center">Sign in to start casting photos</h2>
        )}
      </div>
      <div className="flex w-full flex-col justify-center items-center">
        {!farcasterUser?.status && (
          <div>
            <Terms
              loading={loading}
              startFarcasterSignerProcess={startFarcasterSignerProcess}
            />
          </div>
        )}
        {farcasterUser?.status === "pending_approval" &&
          farcasterUser?.signerApprovalUrl && (
            <Card className="w-full">
              <CardContent className="flex w-full flex-col items-center justify-center gap-5">
                <QRCode
                  className="mt-5"
                  value={farcasterUser.signerApprovalUrl}
                  size={200}
                />
                <h2 className="text-2xl font-bold">Use link below on mobile</h2>
                <Button asChild className="w-full">
                  <Link
                    href={farcasterUser.signerApprovalUrl}
                    className="w-full"
                  >
                    Open Link
                  </Link>
                </Button>
                <Button className="w-full" onClick={logout}>
                  Cancel
                </Button>
              </CardContent>
            </Card>
          )}
      </div>
    </div>
  );
};

components/login-window.tsx

This will take our Farcaster identity as a parameter and allow us to create a QR code for the user to scan or a link for them to open on mobile. It will also show if they’re logged in and if they want to log out. Once a user is signed in and set, we can fetch their signer key throughout the rest of the app and allow them to send casts.

Image Uploads

Now that we have our user signed in, the next thing we need to do is handle the image uploads. Casts don’t take actual files, but rather they need an embed URL we can add to the castAddBody. To get that url we’ll upload the user’s image to Pinata and get a URL in return. The first thing we’ll do to start this process is get a temporary Pinata API key for the upload. Typically you only want to conduct uploads on the backend so the API keys are not exposed on the client, however, Vercel has a file size of what can be passed through an API route. To counteract that we’ll use Pinata’s user endpoint to create a temporary “signed” JWT that will expire after we have used it. That way we can upload from the client and not worry about size restrictions. To do that we’ll use the following endpoint in app/api/keys/route.ts.

import { NextRequest, NextResponse } from "next/server";
const { v4: uuidv4 } = require("uuid");
const pinataJWT = process.env.PINATA_JWT;

export async function GET(req: NextRequest, res: NextResponse) {
  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 NextResponse.json(keyData, { status: 200 });
  } catch (error) {
    console.log(error);
    return NextResponse.json({ text: "Error creating API Key:" }, { status: 500 });
  }
}

export async function PUT(req: NextRequest, response: NextResponse) {
  try {
    const body = await req.json()
    const keyData = JSON.stringify(body)
    console.log(body)
    const keyDelete = await fetch(
      "https://api.pinata.cloud/users/revokeApiKey",
      {
        method: "PUT",
        body: keyData,
        headers: {
          accept: "application/json",
          "content-type": "application/json",
          authorization: `Bearer ${pinataJWT}`,
        },
      },
    );
    const keyDeleteRes = await keyDelete.json();
    return NextResponse.json(keyDeleteRes);
  } catch (error) {
    console.log(error);
    return NextResponse.json({ text: "Error Deleting API Key:" }, { status: 500 });
  }
}

app/api/keys/route.ts

In this endpoint, we’ll make the temporary key with endpoint restrictions as well as uses, but we’ll also have a PUT endpoint that will delete the key completely once we’re done to be extra secure. Now we can call this in our utils/upload-file.ts function.

export async function uploadFile(selectedFile: any) {
  let key;
  let keyId;
  let fileCID;
  let link
  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);
  }

  try {
    const formData = new FormData();
    formData.append("file", selectedFile);

    const metadata = JSON.stringify({
      name: `${selectedFile.name}`,
    });
    formData.append("pinataMetadata", metadata);

    const options = JSON.stringify({
      cidVersion: 1,
    });
    formData.append("pinataOptions", options);

    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;
    console.log(fileCID);
    const fileExtensions:any = {
      'image/jpeg': 'jpeg',
      'image/png': 'png',
      'image/webp': 'webp',
      'image/gif': 'gif'
    };
    const selectedFileType = selectedFile.type;
    const defaultExtension = 'png';
    const fileExtension = fileExtensions[selectedFileType] || defaultExtension;
    link = `${process.env.NEXT_PUBLIC_GATEWAY}/ipfs/${fileCID}?img-width=1080&filename=image.${fileExtension}`
  } catch (error) {
    console.log("Error uploading file:", error);
  }

  try {
    const deleteData = JSON.stringify({
      apiKey: keyId,
    });
    console.log(deleteData);
    const deleteKey = await fetch("/api/key", {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: deleteData,
    });
    const deleteJson = await deleteKey.json();
    console.log(deleteJson);
  } catch (error) {
    console.log("Error deleting API key:", error);
  }

  return link
}

utils/upload-file.ts

There’s a few things going on here. First, we make a request to api/keys to make a temporary API key. Then we create an upload to Pinata with the file name in the pinataMetadata . This will return a CID, which we can use in our Dedicated Gateway link (necessary for fetching and rendering files on a website). What we do on the gateway level is interesting. First, we check the image type to make sure we append it correctly to our URL, as other clients will be looking for that. We also add a query parameter of img-width=1080 to make sure the width of our image does not exceed 1080px. This is big as it not only keeps our app fast but other clients that have to fetch the images from the castAddBody and render it to their clients. Once we have this link created we can return it to be used in the cast sent out.

Sending the Cast

Now that we have a link for our image to use as an embed, as well as our signer, we can finally send a cast! To do that we’re going to use our components/upload-form.tsx.

"use client";

import { useState } from "react";
import { uploadFile } from "@/utils/upload-files";
import { FarcasterUser } from "@/utils/types/farcaster-user";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Loader2 } from "lucide-react";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import channels from "@/utils/channels.json";
import type { Channel } from "@/utils/types/channels";

import { z } from "zod";
import Image from "next/image";

const formSchema = z.object({
  cast: z.string().min(2).max(320),
  file: z.any(),
  parentUrl: z.string(),
});

interface FormProps {
  farcasterUser: FarcasterUser;
}

export default function UploadForm({ farcasterUser }: FormProps) {
  const [selectedFile, setSelecteFile] = useState();
  const [imageLoading, setImageLoading] = useState(false);
  const [loading, setLoading] = useState(false);
  const [castComplete, setCastComplete] = useState(false);
  const [castCompleteMessage, setCastCompleteMessage] = useState("");

  async function fileChangeHandler(event: any) {
    setImageLoading(true);
    const file = event.target.files[0];
    console.log(file);
    setSelecteFile(file);
    setImageLoading(false);
  }

  async function reset() {
    setSelecteFile(undefined);
  }

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      cast: "",
      file: "",
    },
  });

  async function onSubmit(values: z.infer<typeof formSchema>) {
    try {
      setCastCompleteMessage("")
      setLoading(true);
      const fileLink = await uploadFile(selectedFile);
      const data = JSON.stringify({
        signer: farcasterUser.privateKey,
        fid: farcasterUser.fid,
        link: fileLink,
        castMessage: values.cast,
        parentUrl: values.parentUrl,
      });
      const submitMessage = await fetch("/api/message", {
        method: "POST",
        headers: {
          contentType: "application/json",
        },
        body: data,
      });
      const messageJson = await submitMessage.json();
      console.log(messageJson);
      if (submitMessage.status != 200) {
        setLoading(false);
        setCastComplete(true);
        setCastCompleteMessage("Problem sending cast");
      }
      setLoading(false);
      setCastComplete(true);
      setCastCompleteMessage("Cast Sent!");
    } catch (error) {
      console.log(error);
      setLoading(false);
      setCastComplete(true);
      setCastCompleteMessage("Problem uploading file");
    }
  }

  function ButtonLoading() {
    return (
      <Button className="w-full" disabled>
        <Loader2 className="mr-2 h-4 w-4 animate-spin" />
        Please wait
      </Button>
    );
  }

  if (castComplete) {
    return (
      <div className="flex justify-center items-center h-full">
        <h2 className="text-xl font-bold">{castCompleteMessage}</h2>
      </div>
    );
  }

  return (
    <div className="flex flex-col flex-grow justify-center items-center">
      <Form {...form}>
        <form
          onSubmit={form.handleSubmit(onSubmit)}
          className="w-full space-y-8"
        >
          {selectedFile && !imageLoading && (
            <div className="relative">
              <Image
                className="max-h-[250px] rounded-md sm:max-h-[500px] h-auto object-cover hover:cursor-pointer hover:opacity-80"
                width={500}
                height={500}
                src={URL.createObjectURL(selectedFile)}
                alt="User image"
                onClick={reset}
              />
            </div>
          )}
          {imageLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
          {!selectedFile && (
            <Input
              placeholder="file"
              type="file"
              onChange={fileChangeHandler}
            />
          )}
          <FormField
            control={form.control}
            name="cast"
            render={({ field }) => (
              <FormItem className="w-full">
                <FormLabel>Cast</FormLabel>
                <FormControl>
                  <Input
                    disabled={loading ? true : false}
                    placeholder="Image uploaded from PhotoCast"
                    {...field}
                  />
                </FormControl>
                <FormDescription>Cast / Caption</FormDescription>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name="parentUrl"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Channel</FormLabel>
                <Select
                  onValueChange={field.onChange}
                  defaultValue={field.value}
                >
                  <FormControl>
                    <SelectTrigger>
                      <SelectValue placeholder="Select a channel to cast in" />
                    </SelectTrigger>
                  </FormControl>
                  <SelectContent>
                    {channels.map((channel: Channel) => (
                      <SelectItem value={channel.url}>
                        {channel.name}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              </FormItem>
            )}
          />
          {loading ? (
            ButtonLoading()
          ) : (
            <Button className="w-full" type="submit">
              Submit
            </Button>
          )}
        </form>
      </Form>
    </div>
  );
}

components/upload-form.tsx

There’s a lot of code here, but the majority of it is the UI and handling the form inputs which include our file, text, and channel that we want to cast in. The real magic is in our onSubmit() function. In there, we get a fileLink by using our uploadFile function, and then we can pass it into an object called data. In this object, we’ll pass in our signer from the login, the FID of the user, the fileLink, and the text the user inputted. Then we’ll submit that data to an API endpoint app/api/message/route.ts.

import {
  Message,
  NobleEd25519Signer,
  FarcasterNetwork,
  makeCastAdd,
} from "@farcaster/core";
import { hexToBytes } from "@noble/hashes/utils";
import { NextRequest, NextResponse } from "next/server";

const NETWORK = FarcasterNetwork.MAINNET; // Network of the Hub

export async function POST(req: NextRequest, res: NextResponse) {
  try {
    const body = await req.json();
    console.log(body);
    const SIGNER = body.signer;
    const FID = body.fid;
    const link = body.link;
    const message = body.castMessage;
    const parentUrl = body.parentUrl;
    if (!link) {
      console.log("Must have link");
      return NextResponse.json({ error: "No link provided" });
    }

    const dataOptions = {
      fid: FID,
      network: NETWORK,
    };
    // Set up the signer
    const privateKeyBytes = hexToBytes(SIGNER.slice(2));
    const ed25519Signer = new NobleEd25519Signer(privateKeyBytes);

    const castBody = {
      text: message,
      embeds: [{ url: link }],
      embedsDeprecated: [],
      mentions: [],
      mentionsPositions: [],
      parentUrl: parentUrl,
    };

    const castAddReq: any = await makeCastAdd(
      castBody,
      dataOptions,
      ed25519Signer,
    );
    const castAdd: any = castAddReq._unsafeUnwrap();

    const messageBytes = Buffer.from(Message.encode(castAdd).finish());

    const castRequest = await fetch(
      "https://hub.pinata.cloud/v1/submitMessage",
      {
        method: "POST",
        headers: { "Content-Type": "application/octet-stream" },
        body: messageBytes,
      },
    );

    const castResult = await castRequest.json();
    console.log(castResult);

    if (!castResult.hash) {
      return NextResponse.json({ "Error": "Failed to send cast" }, { status: 500 });
    } else {
      let hex = Buffer.from(castResult.hash).toString("hex");
      return NextResponse.json({ hex }, {status: 200});
    }
  } catch (error) {
    console.log(error);
    return NextResponse.json({ "server error": error });
  }
}

app/api/message/route.ts

Here is the sauce you’ve been waiting for: sending the cast. To start we’ll import some helpers from @farcaster/core , including some things that help encode our message and declare the Farcaster network. Then we take in the request body and make those some constants we’ll use in sending to Farcaster, and make sure the user includes a link (it's Photocaster, gotta have images). Next, we’ll set up the signer using new NobleEd25519Signer , as well as the castBody object with our text and our and our embed URL. Then we’ll use makeCastAdd from the Farcaster core library which will help abstract some of the protobuff stuff we would normally need to do manually. In there, we’ll pass in our castBody, dataOptions, and the ed25519Signer. One important step to make this work is this:

const castAdd: any = castAddReq._unsafeUnwrap();

This will format our cast in a way that the Hub API will accept, and huge thanks to KMac on Farcaster for their suggestion here! After we have done this we can turn it into messageBytes with Buffer.from(Message.encode(castAdd).finish()) , then finally make the request to the Hub API to make the cast.

const castRequest = await fetch("https://hub.pinata.cloud/v1/submitMessage", {
  method: "POST",
  headers: { "Content-Type": "application/octet-stream" },
  body: messageBytes,
});

const castResult = await castRequest.json();
console.log(castResult);

Overall this API request is straightforward, it's the preparation that takes a lot of work. After we get the response from the Hub we just make sure a hash is returned or otherwise, we mark it as a failure. That’s it!

Wrapping Up

As you can see there is quite a bit of work that goes into creating a client. Once you have the main moving pieces of fetching a feed, getting a signer, and sending casts, you can move from there to build even more functionality such as fetching PFPs, enabling likes, and more! The true power of this model is Pinata’s method for handling media in a sufficiently decentralized way while also being able to use the Pinata Hub at the same time. We’ll continue to build both IPFS and Farcaster tools to help empower client creation and make things more and more seamless.

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.