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.
NodeJS v20+ (I'm using 22.2.0)
NPM v10+ (I'm using 10.7.0)
ngrok (we'll use this for testing our Frame)
A Vercel account (a free one is fine for our purposes)
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 calledroute.tsx
Add a folder called
gql
under theapi
folder.
Add a file under
gql
calledgetUserOwnedFanTokens.ts
and add the following codeimport { 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
calledgetVestingContractAddress.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
calledutils
and place a file there calledui.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'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!