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.
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
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"
}
}
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 👇
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
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!
Anyone have a guide they can link me to on frame building?
Just published one recently but you may want to start with some no-code tools. This guide can take you from 0-1 but it’s more intermediate. https://paragraph.xyz/@48hrs/how-to-develop-a-farcaster-frame-for-moxie
I created this Moxie Frame to accompany a tutorial (linked below) I wrote both as a desk reference for myself but also a hopefully useful guide for others looking to use Frog to develop Frames. It covers routing, sharing embedded content, styling, and dynamic pagination. https://moxie-fan-frame.vercel.app/api
https://paragraph.xyz/@48hrs/how-to-develop-a-farcaster-frame-for-moxie
Great guide ❤️ Will be jumping back on frog for my next frame build!!
Let me know how you get on! 🙏
Discover how to create a Frame on Farcaster to view your Moxie Fan Token portfolio with this informative tutorial by @nickysap. Aimed at developers of all levels, it covers prerequisites, code configurations, and deployment steps for engaging with the Moxie protocol effectively.