How to Develop a Farcaster Frame for Moxie

Build your first frame, start to finish

Motivation

Unlike most social apps, which hoard data and capitalize on the network effects brought about by their users, Farcaster enables developers to build unique experiences that leverage the network's social graph.

While having deep knowledge of full-stack web development or programming skills to build a Frame is unnecessary, a basic understanding will help. If you want to get an idea out there, there are no-code solutions for the less technically inclined.

For this tutorial, we're going to build a Frame for Moxie. A new social token protocol that lives on top of Farcaster, which rewards users for engagement and enables content creators to share earnings with their fans.

The frame we're building will enable users to see basic information about the fan tokens they hold.

Note: If you don't want to follow this tutorial but want to jump into the code, you can clone my example repo.

Prerequisites

Before we start, you'll want to ensure you have the following prerequisites installed.

  1. NodeJS v20+ (I'm using 22.2.0)

  2. NPM v10+ (I'm using 10.7.0)

  3. ngrok (we'll use this for testing our Frame)

  4. A Vercel account (a free one is fine for our purposes)

  5. A Neynar account

Set up your environment

First, let's get a directory set up for our environment

mkdir moxie-fans && cd moxie-fans
npx create-next-app@latest ./

I recommend the following configuration

Now, install our dependencies.

npm install --save dotenv frog hono graphql graphql-request ethers

WARNING: The next step is crucial for operation with Frog and NextJS.

Navigate to your .eslintrc.json file and add a rule to ignore jsx

{
  "extends": "next/core-web-vitals",
  "rules": {
    "react/jsx-key": "off"
  }
}

Set up file structure & configs

Next, we must set up files and configs to enable our Frame.

  • Navigate to src/app/api and make a new folder called [[...routes]].

    • Within [[...routes]] add a file called route.tsx

  • Add a folder called gql under the api folder.

  • Add a file under gql called getUserOwnedFanTokens.ts and add the following code

    import { gql } from "graphql-request";
    import { GraphQLClient } from "graphql-request";
    
    export const graphQLClient = new GraphQLClient(
     "https://api.studio.thegraph.com/query/23537/moxie_protocol_stats_mainnet/version/latest"
    );
    
    export const getUserOwnedFanTokens = async (address: string): Promise<any> => {
        const query = gql`
        query MyQuery($userAddresses: [ID!]) {
            users(where: { id_in: $userAddresses }) {
              portfolio {
                balance
                buyVolume
                sellVolume
                subjectToken {
                  name
                  symbol
                }
              }
            }
          }
        `;
    
        const variable = {
            userAddresses: [address],
        };
    
        try {
            const data = await graphQLClient.request(query, variable);
            return data;
        } catch (e: any) {
            console.error("GraphQL request error:", e.message);
            return null;
        }
    }
  • The above code will enable us to pass in the frame user's address(es) and retrieve their portfolio. However, because most of the $MOXIE in the ecosystem is held in the vesting contract, we also need to get the relational address where the tokens are locked based on the beneficiary address we retrieve from the Frame.

  • Add another file under gql called getVestingContractAddress.ts and paste in the following code.

    import { gql } from "graphql-request";
    import { GraphQLClient } from "graphql-request";
    
    const graphQLClient = new GraphQLClient(
        "https://api.studio.thegraph.com/query/23537/moxie_vesting_mainnet/version/latest"
      );
    
    export const getVestingContractAddress = async (address: string): Promise<any> => {
        
        const query = gql`
            query MyQuery($beneficiary: Bytes) {
                tokenLockWallets(where: {beneficiary: $beneficiary}) {
                    address: id
                }
            }
        `;
    
        const variable = {
            beneficiary: address
        };
    
        try {
            const data = await graphQLClient.request(query, variable);
            return data;
          } catch (e: any) {
            console.error("GraphQL request error:", e.message);
            return null;
          }
    };
  • Create a new file in your top-level directory called .env.local and add the following variables (replacing YOURKEY with your API key).

    NEYNAR_API_URL="https://api.neynar.com/v2/farcaster"
    NEYNAR_API_KEY="YOURKEY"

NOTE: The following configuration is optional, but I recommend it for easily styling your Frames.

  • Add a new directory src/app called utils and place a file there called ui.ts. Add the following code.

    import { createSystem } from 'frog/ui'
     
    export const {
      Box,
      Columns,
      Column,
      Heading,
      HStack,
      Rows,
      Row,
      Spacer,
      Text,
      VStack,
      vars,
    } = createSystem()

Finally, let's get our ngrok server started to serve the Frames locally and test them in the Frame Validator.

ngrok http http://localhost:3000

The above command will start up an ngrok server that will provide you with a URL you can use in the following steps. Keep this terminal window running and the URL handy.

Your file structure should look like this 👇

Create the Frame routes

Ok, we're finally ready to get creative and implement some interactivity!

Head back to route.tsx and import the necessary modules at the top of the file.

/** @jsxImportSource frog/jsx */
import { Button, Frog, TextInput } from 'frog';
import { handle } from 'frog/next';
import { neynar as neynarHub } from 'frog/hubs';
import { neynar as neynarMiddleware } from 'frog/middlewares';
import { ethers } from 'ethers';
import { Box, Text, vars } from '@/app/utils/ui'; // Leave this out if you're not using frog ui
import { getUserOwnedFanTokens } from './gql/getUserOwnedFanTokens';
import { getVestingContractAddress } from '../gql/getVestingContractAddress';

const INTENT_FOLLOW_ME = 'https://warpcast.com/nickysap'; // you can change this to your profile if you'd like

Next, let's instantiate Frog with the required configurations

const { NEYNAR_API_KEY } = process.env;

type State = {
    portfolio: any[];
};

const app = new Frog<{ State: State }>({
    title: 'Moxie Portfolio',
    ui: { vars },
    assetsPath: '/',
    basePath: '/api',
    imageAspectRatio: '1:1',
    imageOptions: {
        height: 1024,
        width: 1024,
    },
    initialState: {
        portfolio: [],
    },
    hub: neynarHub({ apiKey: NEYNAR_API_KEY }),
    verify: process.env.NODE_ENV === 'production',
    origin: BASE_URL,
    headers: { 'cache-control': 'max-age=0' },
}).use(
    neynarMiddleware({
        apiKey: NEYNAR_API_KEY,
        features: ['interactor', 'cast'],
    })
);

IMPORTANT: Place the following lines at the bottom of your routes.tsx file.

export const GET = handle(app);
export const POST = handle(app);

Keep the above code below your routes throughout the tutorial.

Now, we will set up our routes.

The first route is just the landing Frame. We will keep it simple.

app.frame('/', (c) => {
    return c.res({
        image: (
            <Box
                alignVertical='center'
                backgroundImage='url(https://moxie.xyz/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ffans.74a7f7cf.png&w=1200&q=75)'
                backgroundColor='bg'
                justifyContent='center'
                paddingTop='52'
                paddingLeft='80'
                paddingRight='80'
                backgroundSize='120% 150%'
                backgroundPosition='top -10%'
            >
                <Box
                    backgroundColor='linear'
                    borderTopLeftRadius='80'
                    borderTopRightRadius='80'
                    paddingTop='20'
                    paddingLeft='20'
                    paddingRight='20'
                    height='100%'
                    width='100%'
                    alignContent='center'
                    justifyContent='center'
                >
                    <Text color='white' align='center' size='32'>
                        What&apos;s in your wallet?
                    </Text>
                    <Spacer size='24' />
                    <Text color='white' align='center' size='20'>
                        Check your Moxie Fan Token Portfolio
                    </Text>
                </Box>
            </Box>
        ),
        intents: [
            <Button value='check'>Portfolio</Button>,
            <Button.Link href={INTENT_FOLLOW_ME}>Follow me</Button.Link>,
        ],
        action: '/portfolio',
    });
});

The above code creates a frame with a background image and some text with two "intents." Intents are just actions the user can take on this Frame. In our case, we allow them to check their portfolio or follow my profile. Finally, the action tells the frame to send a request to the server and hit the /portfolio endpoint, which we will now create.

You can go to the Frames Validator and check out your work if you'd like.

app.frame('/portfolio', async (c) => {
    const { deriveState } = c;
    const state = await deriveState(async (previousState: any) => {
        const address = c.var.interactor?.verifications[0] as string;
        const vestingContractAddress = await getVestingContractAddress(address);
        const response = await getUserOwnedFanTokens(
            vestingContractAddress?.tokenLockWallets[0]?.address
        );
        previousState.portfolio = response?.users[0]?.portfolio || [];
        return previousState;
    });

    if (!state.portfolio || state.portfolio.length === 0) {
        return c.res({
            image: (
                <Box
                    grow
                    backgroundColor='purple400'
                    display='flex'
                    alignItems='center'
                    justifyContent='center'
                >
                    <Text>No Fan Tokens Found</Text>
                </Box>
            ),
            intents: [
                <Button value='back' action='/'>
                    Go back
                </Button>,
            ],
        });
    }

    return renderPortfolio(c, state, 0);
});

This code uses Frog's state management solution to derive and set the state of the user's portfolio. If there is nothing in the user's portfolio, we let them know and give them the option to return. If we find Fan Tokens in the portfolio, we use the renderPortfolio function.

Which reminds me, we need to write the renderPortfolio function!

Place the following code above your /portfolio endpoint.

function renderPortfolio(c: any, state: any, page: number = 0) {
    const itemsPerPage = 5;
    const start = page * itemsPerPage;
    const end = start + itemsPerPage;
    const portfolioToShow = state.portfolio.slice(start, end);
    const portfolioLength = state.portfolio.length;
    const hasMore = portfolioLength > end;
    const address = c.var.interactor?.verifications[0] as string;
    return c.res({
        image: (
            <Box
                alignVertical='center'
                backgroundImage='url(https://moxie.xyz/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ffans.74a7f7cf.png&w=1200&q=75)'
                backgroundColor='bg'
                justifyContent='center'
                paddingTop='30'
                paddingLeft='80'
                paddingRight='80'
                backgroundSize='120% 150%'
                backgroundPosition='top -10%'
            >
                <Box
                    backgroundColor='linear'
                    borderTopLeftRadius='80'
                    borderTopRightRadius='80'
                    paddingTop='20'
                    paddingLeft='20'
                    paddingRight='20'
                    height='100%'
                    width='100%'
                    alignContent='center'
                    justifyContent='center'
                >
                    <Text color='white' align='center' size='32'>
                        Your Moxie Fan Token Portfolio
                    </Text>
                    <Spacer size='24' />
                    {portfolioToShow.map((token: any) => (
                        <Box
                            display='flex'
                            flexDirection='row'
                            gap='4'
                            alignItems='flex-start'
                            justifyContent='center'
                        >
                            <Text color='purple400' size='20'>
                                {token.subjectToken.name}:
                            </Text>
                            <Text color='white' size='20'>
                                {parseInt(ethers.formatUnits(token.balance, 18))
                                    .toFixed(2)
                                    .toLocaleString()}
                            </Text>
                        </Box>
                    ))}
                    {hasMore && (
                        <Box
                            display='flex'
                            alignContent='center'
                            justifyContent='center'
                            paddingTop='10'
                        >
                            <Text color='white' align='center' size='16'>
                                Plus {portfolioLength - end} more
                            </Text>
                            <Text color='white' align='center' size='16'>
                                Click below to show more
                            </Text>
                        </Box>
                    )}
                </Box>
            </Box>
        ),
        intents: [
            <Button action={`/stats/${address}`} value='stats'>
                Stats
            </Button>,
            hasMore && (
                <Button action={`/portfolio/${page + 1}`} value='more'>
                    More
                </Button>
            ),
            page > 0 && (
                <Button action='/portfolio' value='back'>
                    Back
                </Button>
            ),
            !hasMore && (
                <Button action={`/stats/${address}`} value='stats'>
                    Stats
                </Button>
            ),
        ].filter(Boolean),
    });
}

This function will enable us to paginate the user's portfolio, only showing five holdings per page to keep things visually appealing.

Now that we have this function, we can write the pagination endpoint.

app.frame('/portfolio/:page', async (c) => {
    const page = parseInt(c.req.param('page') || '0');
    const { deriveState } = c;
    const state = await deriveState(async (previousState: any) => {
        if (!previousState.portfolio || previousState.portfolio.length === 0) {
            const address = c.var.interactor?.verifications[0] as string;
            const vestingContractAddress = await getVestingContractAddress(
                address
            );
            const response = await getUserOwnedFanTokens(
                vestingContractAddress?.tokenLockWallets[0]?.address
            );
            previousState.portfolio = response?.users[0]?.portfolio || [];
        }
        return previousState;
    });

    return renderPortfolio(c, state, page);
});

The above endpoint requests the page number and displays the user's fan tokens based on the previous cursor.

Now, let's work on the /stats Frame will also have a share option so the user can share their stats as a Frame and entice others to check their stats and do the same.

app.frame('/stats/:address', async (c) => {
    const address = c.req.param('address');
    const vestingContractAddress = await getVestingContractAddress(address);
    const response = await getUserOwnedFanTokens(
        vestingContractAddress?.tokenLockWallets[0]?.address
    );
    const totalTokens = response?.users[0].portfolio.length;
    const totalSupply = response?.users[0].portfolio.reduce(
        (acc: any, token: any) =>
            acc + parseInt(ethers.formatUnits(token.balance, 18)),
        0
    );
    const buyVolume = response?.users[0].portfolio.reduce(
        (acc: any, token: any) =>
            acc + parseInt(ethers.formatUnits(token.buyVolume, 18)),
        0
    );
    const sellVolume = response?.users[0].portfolio.reduce(
        (acc: any, token: any) =>
            acc + parseInt(ethers.formatUnits(token.sellVolume, 18)),
        0
    );

    const castUrl = 'https://warpcast.com/~/compose';
    const castText = 'I checked my Moxie Fan Token portfolio. Check yours out here!';
    const embedUrl = `${BASE_URL}/api/stats/${address}`;
    const INTENT_SHARE_FRAME = `${castUrl}?text=${encodeURIComponent(
        castText
    )}&embeds[]=${encodeURIComponent(embedUrl)}`;

    return c.res({
        image: (
            <Box
                alignVertical='center'
                backgroundImage='url(https://moxie.xyz/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Ffans.74a7f7cf.png&w=1200&q=75)'
                backgroundColor='bg'
                justifyContent='center'
                paddingTop='52'
                paddingLeft='80'
                paddingRight='80'
                backgroundSize='120% 150%'
                backgroundPosition='top -10%'
            >
                <Box
                    backgroundColor='linear'
                    borderTopLeftRadius='80'
                    borderTopRightRadius='80'
                    paddingTop='20'
                    paddingLeft='20'
                    paddingRight='20'
                    height='100%'
                    width='100%'
                    alignContent='center'
                    justifyContent='center'
                >
                    <Text color='white' align='center' size='32'>
                        Your Moxie Fan Token Stats
                    </Text>
                    <Spacer size='24' />
                    <Box
                        display='flex'
                        flexDirection='row'
                        gap='4'
                        alignItems='center'
                        justifyContent='center'
                    >
                        <Text color='purple400' size='20'>
                            Total Tokens:
                        </Text>
                        <Text color='white' size='20'>
                            {totalTokens}
                        </Text>
                    </Box>
                    <Box
                        display='flex'
                        flexDirection='row'
                        gap='4'
                        alignItems='center'
                        justifyContent='center'
                    >
                        <Text color='purple400' size='20'>
                            Total Supply:
                        </Text>
                        <Text color='white' size='20'>
                            {totalSupply.toFixed(2).toLocaleString()}
                        </Text>
                    </Box>
                    <Box
                        display='flex'
                        flexDirection='row'
                        gap='4'
                        alignItems='center'
                        justifyContent='center'
                    >
                        <Text color='purple400' size='20'>
                            Buy Volume ($MOXIE):
                        </Text>
                        <Text color='white' size='20'>
                            {buyVolume > 0
                                ? buyVolume.toFixed(2).toLocaleString()
                                : '0'}
                        </Text>
                    </Box>
                    <Box
                        display='flex'
                        flexDirection='row'
                        gap='4'
                        alignItems='center'
                        justifyContent='center'
                    >
                        <Text color='purple400' size='20'>
                            Sell Volume ($MOXIE):
                        </Text>
                        <Text color='white' size='20'>
                            {sellVolume > 0
                                ? sellVolume.toFixed(2).toLocaleString()
                                : '0'}
                        </Text>
                    </Box>
                </Box>
            </Box>
        ),
        intents: [
            <Button action='/portfolio' value='back'>
                Back
            </Button>,
            <Button.Link href={INTENT_SHARE_FRAME}>Share Frame</Button.Link>,
        ],
    });
});

This Frame takes some of the additional information returned by our GraphQL query, formats it, and displays it at a glance for the user. This Frame could help check your total balance of Fan Tokens to help qualify your Far Boost score.

If the user selects 'Share Frame,' they will be led to a newly crafted cast with their user-specific stats already populated in-frame as an embed.

Now that we have a nice Frame with some useful interactivity and

Deploy

Set up your git repository and get the URL, then initialize git locally and get yourself all set up.

git init
git add .
git commit -m "first commit"
git branch -M main
git remote add origin <YOUR-REPO-URL>
git push -u origin main

Next, head over to Vercel and add a new project.

Import your newly created GitHub repository, and don't forget to add your environment variables!

Once deployed, you can head over to the Frames Validator and paste in the URL Vercel provides you. Just remember to add /api at the end if you followed this tutorial.

Voila! You have just developed your first Moxie Frame!

Loading...
highlight
Collect this post to permanently own it.
48 Hours From Monday logo
Subscribe to 48 Hours From Monday and never miss a post.