Cover photo

A Saturday spent building Farcaster frames

Fantastic Farcaster frame fun

It's a Saturday. Lately, my Saturdays have been full of building random things. My updated blog (hopefully you're reading this post on there!) was a Saturday creation. Building & learning how to use my plotter was a Saturday / Sunday thing...Saturdays are awesome because it generally means unstructured time to do that tickles my interests.

So, this morning I woke up and checked the Farcaster feed. There were a lot of posts in frames and some of the posts had embeds with buttons...what?! The first frame on my feed was by @w1nt3r and it was just a click away to see how high the counter would go. At this point the concept of frames totally clicked and I was hooked. Pardon the pun.

@w1nt3r - the first frame I saw

The first thing to appreciate is how straightforward the onboarding process for building a frame is. The documentation is well written, and a near step-by-step guide with example repository can be found in the notion here:

The notion also provides a template git repository for a poll frame application. It's written in NextJS and can be hooked straight up to Vercel. All that needs to be configured is the HOST env variable and the Vercel KV Redis database. Coincidentally, before today I did not know about the Vercel KV db. It looks pretty powerful and useful. Definitely need to look into it further. But that's for another day, and another post.

The GitHub also provides template code for reading Farcaster IDs. So I got to work ripping apart and understanding the template GitHub. The first thing I wanted was to get rid of the 'poll' element. I just wanted users to see a counter, similar to @w1nt3rs embed. Everything in the template is in TypeScript so it was super easy to see the types being expected everywhere. Refactoring the code so it would just be a title and a counter took a couple of hours, but meant I got into all the open graph features, and data fetching / storage features. The main refactoring took place in the actions.tsx class. In here, I created an object that just stored the name of the 'hit' that shows. Here's the updated code:

export async function saveNumHits(numhits: NumHits, formData: FormData) {
  let newNumHits = {
    title: formData.get("title") as string,

  await kv.hset(`num_hits:${}`, newNumHits);
  await kv.zadd("num_hits_by_date", {
    score: Number(numhits.created_at),


export async function giveHit(numHits: NumHits) {
  await kv.hincrby(`num_hits:${}`, `numHits`, 1);


export async function redirectToNumHits() {

I retained the created_at logic and the title logic. This proved pretty helpful as I was able to create and view all previous test runs in one view, and see previous mistakes.

Finally, I checked out the /api/vote endpoint and made some changes. The main change was removing all the optionality that would be there for a poll. Instead, it would just be tracking integers and Farcaster IDs. The key logic I wanted to keep: If someone had already taken their free vibe, they could not take another free vibe. The code for that can be seen below:

      const fid = validatedMessage?.data?.fid || 0;
      const alreadyVoted = await kv.get(`num_hits:${numHitsId}:votes:${fid}`);
      voted = voted || !!alreadyVoted;

      if (!results && !voted) {
        let multi = kv.multi();
        multi.hincrby(`num_hits:${numHitsId}`, `numHits`, 1);
        multi.set(`num_hits:${numHitsId}:votes:${fid}`, true);
        await multi.exec();

Above, the code assumes validateMessage is successfully storing the FID of the person interacting. Then it checks the @vercel/kv store for the key of that particular number of hits frame, and the FID of the person interacting. If the caster has already voted, then it does not apply the hit. If the caster has not already voted, then the hit is applied and the key/value pair for that caster is updated to true. Nice! Working on this bit was interesting to learn about - two new concepts for me...retrieving and using an FID (more on that later) and using @vercel/kv to store data.

Finally, I was ready to do some UI work. The template app generates dynamic HTML to generate the OpenGraph hero. So, I chose a free font, chose the greenest background possible, found a purple colour palette I liked and set everything up.

I toyed around with a few ideas - gm was the first, then the thumbnail would say something along the lines of "times gm'd". Eventually I landed on free vibe. Pretty happy with the choice.

free vibe - my first frame

Alright grand! I posted this on frames, then started looking at what other people had been working on. Man, people are using frames for really interesting things. @df compiled a list of all the frames as of a certain time. It can be found here:

Here are some of my favourites...

Zoomer or Boomer
onceupon onchain feed
playing pokemon

The frame stood out to me for potential use cases. Recently, we released a profile page on the Bright Moments portal. The main purpose of the profile page is to track progress to certain goals within the BM ecosystem. A Frame is a perfect place to plonk this data! There can be a default image for no citizens, then use the FID of the person interacting to check which Crypto Citizens they are holding, using the API we built for the profile page.

These are the technical challenges I faced:

  • Resolving an FID to the connected wallet address. New to this, don't be too mean!

  • Retrieving potentially large numbers of citizen images. Some wallets multiple hundreds of CryptoCitizens.

  • Displaying all citizen images in a way that makes it clear and doesn't look...awful

Okay! Great challenges to have. The first challenge I solved was the resolving an FID to a wallet address. This was the easiest by far and I used to achieve my goal. Searchcaster provides a very simple to use API for finding information about FIDs. Here is the API request for retrieving account information for the account with FID 1:

    "body": {
        "id": 1,
        "address": "0x8773442740C17C9d0F0B87022c722F9a136206eD",
        "username": "farcaster",
        "displayName": "Farcaster",
        "bio": "A sufficiently decentralized social network.",
        "followers": 1435,
        "following": 2,
        "avatarUrl": "",
        "isVerifiedAvatar": false,
        "registeredAt": 1628882889000
    "connectedAddress": "0x86924c37a93734e8611eb081238928a9d18a63c0",
    "connectedAddresses": [

Okay, next challenge - image data. Originally, I wanted all citizens to appear and take up the entire tile. This went great for wallets with fewer than 50 citizens. However, 50 and above, the requests just intermittently timed out and sometimes the Open Graph image didn't load. Not ideal. Clearly, displaying all citizens was not a feasible option.

I changed tact and decided to display just full sets. Similar to the full set tracker on the Bright Moments profile which makes uncollected backgrounds slightly higher in opacity

This worked a treat and sort of killed two birds with one stone. I was able to parse large amounts of data, taking just one citizen with each background, and display everything well. I tried with some of the higher collectors - people with 600+ citizens, and it worked a charm. Farcaster have provided a great tool for testing frames. It can be found here:

A drawback I found in testing with this tool is that it didn't consider my currently connected FID. I may have been misusing the tool, but it took me a good few minutes to track that one down. One nuance here, is that the /api/image endpoint, which generates the final OpenGraph SVG, can not read the state of the request. Instead, the /api/vote endpoint is posted to by the frame itself. The code makes this pretty clear...

  const fcMetadata: Record<string, string> = {
    "fc:frame": "vNext",
    "fc:frame:post_url": `${process.env["HOST"]}/api/generate`,
    "fc:frame:image": `${process.env["HOST"]}/api/image`,
    "fx:frame:button1": "View Citizens",

I admit, I did initially try to fetch the FID in the image route. It didn't work. But glad I tried. I did some tidy up, and made sure the default image state looked polished and reflected how the profile looks on Bright Moments. I ensured the default states worked for wallets with many citizens, no citizens and a few citizens - success! The final result is something that I am proud of.

The default state - pre checking your full set
The post checked state - after hitting 'Check Yours'

The image is generated on this URL:<FID>

I hope you have fun playing around with this, I definitely did while building it! Overall, a productive Saturday spent learning about a new technical concept and producing something useful & fun. Have a nice weekend!

Collect this post to permanently own it.
henrypye logo
Subscribe to henrypye and never miss a post.
  • Loading comments...