Back to blog
How to Build a Farcaster Native Gumroad on Base
If you’ve never used Gumroad before, it’s a platform that allows users to sell digital content. Something key to building such a platform is keeping the gated files secure, and that’s exactly what Pinata’s new Files API does. With that said, when we saw the below cast from Dan Romero, founder of Farcaster, we couldn’t help ourselves but take up the challenge and build it.
What we ended up with is a new platform called CandyRoad, where you can sign in with your Farcaster account or crypto wallet, create a frame to sell your content, and share it to Warpcast — all within 30 seconds. The frame will allow anyone to buy a file on Base and redeem it from the social feed, as well as send the frame to your DCs (direct casts) so you can redeem it later. At the core of it all is the Files API, allowing you to have public files to be used for cover images, and private files for the content to be sold, taking advantage of Farcaster’s native signatures to authenticate users.
In this post, we’ll show you how to build your own CandyRoad, down to the very last piece. If you ever want a reference you can check out the source code for CandyRoad here!
Setup
This is a fairly complex app with several moving pieces, so we’ll take a moment to setup everything we need before we write any code.
Pinata
Naturally, you will need a Pinata account to build this out. Believe it or not, you can do this whole tutorial on a free Pinata account! Just sign up here to get started. Once you have an account, you will want to create an API Key by clicking on the API Keys tab, then clicking “New Key” in the top right. Give the key admin permissions without limited uses, and once it’s created, save the info somewhere safe (we’ll primarily be using the longer JWT).
The other piece you’ll need from Pinata is your gateway domain; this will be the URL where all your files will travel through. Visit the Gateways tab on the left hand side, then save the already created gateway domain which should look something like whimsy-pinnie-100.mypinata.cloud
. Copy it down just as you see it and you’ll be all set!
Privy
To handle the auth on our app, we’ll be using Privy, which is perhaps one of the best Web3 auth experiences out there. One major benefit is the ability to allow people to either sign in with Farcaster or their crypto wallet. To get started, visit their site and create a new account, as well as a new app. Give it a name and mark it as a web app, and once created, go to the settings tab. First, you’ll want to grab the App ID
, which is a public id, then you will want to click on “Verify with Key” further down, then copy that key as a secret key.
Supabase
One final thing we’ll need is a simple database table. Most of our data management will actually be handled by Pinata, which makes things simpler, however to help track the purchases of items, we’ll want to use an actual database. Supabase makes this really easy; just make an account as well as a project to start with. Once inside, you can go to the SQL editor on the left side bar and paste this in:
create table
public.purchases (
id bigint generated always as identity not null,
buyer_id integer not null,
cid text null,
constraint purchases_pkey primary key (id)
) tablespace pg_default;
This will create a single table with an id
key, a buyer_id
which would be the FID of a user on Farcaster, and the cid
string of the file being purchased. Then you will want to grab your Project URL and API Key by going to the home page of the project and scrolling down a bit.
Warpcast
One thing we’ll be using in this stack is a programmatic direct cast to the buyer with a link to the frame, that way they can redeem the file whenever they want to. To send programmatic DCs, you’ll need a Farcaster account, as well as a Warpcast API key. Visit their website if you don’t have a Farcaster account already. Once it’s created, visit the API Keys page to make a new key.
Project
For our web app and frame hosting, we’ll use Next.js for simplicity sake. To create a new project, run the following command in the terminal:
npx create-next-app@latest candyroad
Accept all the default options, run this command to move into the project directory, and install our dependencies.
cd candyroad && npm i pinata frog @supabase/supabase-js @supabase/ssr @privy-io/react-auth
To build the UI of our project, we’ll use shadcn/ui, which makes it easy to build nice UIs with very little lift. Run the command below to initialize the project with shadcn.
npx shadcn@latest init
You can accept all the default options here as well, then we’ll go ahead and add all the components for our project in one command.
npx shadcn@latest add button card form dialog input label
Before we setup our environment variables, we still have one small thing we need to knock out. Our frames will contain two files from Pinata: the public cover image and the private file that the user sells. In order to have public files, we need to create a public group in Pinata and then upload the files to said group. To get this group started, I would recommend installing the Files CLI, then running pinata auth
to set it up. Once you do that, creating a group is one simple command:
pinata groups create -p CoverImages
This should return an id
for the group, which you will want to save with your other Pinata info.
With all of our scaffolding setup, we just need to add a few config files to wrap up our setup. To start, create a new file called .env.local
where we will fill out with all our variables.
NEXT_PUBLIC_PRIVY_APP_ID= # The Public Privy App ID
PRIVY_VERIFICATION_TOKEN= # The secret Privy verification token
PINATA_JWT= # The longer Pinata API Key JWT
NEXT_PUBLIC_GATEWAY_URL= # Your Pinata gateway domain in the format example.mypinata.cloud
NEXT_PUBLIC_GROUP_ID= # ID of the public group we made earlier
NEXT_PUBLIC_SUPABASE_URL= # Supabase Project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY= # Supabase API Key
WARPCAST_API_KEY= # The Warpcast API key
Next we’ll update the existing next.config.mjs
file with the following code to allow remote image URLs to go through.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*",
port: "",
pathname: "**/**",
},
],
},
};
export default nextConfig;
Finally, we’ll setup just a few utility files that we’ll use throughout the project. To start, create a folder called utils
in the root of the project, then make a file called pinata.ts
with the following content:
"server only";
import { PinataSDK } from "pinata";
export const pinata = new PinataSDK({
pinataJwt: `${process.env.PINATA_JWT}`,
pinataGateway: `${process.env.NEXT_PUBLIC_GATEWAY_URL}`,
});
This is a pretty simple file that will export an instance of the Pinata SDK we can use throughout the app.
We’ll do something similar for Supabase. Make a folder called supabase
inside of utils
. Then, inside the Supabase folder, make a file called server.ts
and put the code below inside.
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
},
);
}
This is pretty standard practice for setting up a Supabase project in Next, and it lets you add more in later if you need it.
Finally, we’ll add a little helper to verify auth sessions with Privy. Make a new file called session.ts
inside utils
and paste in this code:
import * as jose from "jose";
const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID!;
const PRIVY_PUBLIC_KEY =
`-----BEGIN PUBLIC KEY-----${process.env.PRIVY_VERIFICATION_TOKEN}-----END PUBLIC KEY-----` ||
"";
export const verifySession = async (token: string) => {
console.log({ token });
try {
const verificationKey = await jose.importSPKI(PRIVY_PUBLIC_KEY, "ES256");
const payload = await jose.jwtVerify(token, verificationKey, {
issuer: "privy.io",
audience: PRIVY_APP_ID,
});
// Verify that the sub matches app id
if (payload?.payload?.aud?.includes(PRIVY_APP_ID)) {
return true;
}
return false;
} catch (error) {
console.log(error);
return false;
}
};
If you’re still with us, congrats!! We got the project setup; now we can finally start building this thing out.
User Auth
First thing’s first: we need the ability for user’s to login and authenticate themselves. Privy really does make this a breeze, as we only need two components to add a sign-in flow. For the first piece, make a new file called providers.tsx
inside the components
folder with the following code.
"use client";
import { PrivyProvider } from "@privy-io/react-auth";
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<PrivyProvider
appId={`${process.env.NEXT_PUBLIC_PRIVY_APP_ID}`}
config={{
// Customize Privy's appearance in your app
appearance: {
theme: "light",
accentColor: "#676FFF",
logo: "",
},
loginMethods: ["farcaster", "wallet"],
}}
>
{children}
</PrivyProvider>
);
}
This is a pretty straight forward React wrapper that initialized our Privy App ID along with some other settings, like using farcaster
and wallet
as the login methods. Now we just need to add this to the app/layout.tsx
to wrap our app.
import type { Metadata } from "next";
import Providers from "@/components/providers";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Piece of cake! Next we need a button for user’s to click to login. Privy recently added a fancy new <UserPill />
component that works perfect for this. We’ll create a new component called login-button.tsx
and use the code below.
"use client";
import { UserPill } from "@privy-io/react-auth/ui";
export function LoginButton() {
return <UserPill />;
}
Now let’s add it to our main page.tsx
.
import { LoginButton } from "@/components/login-button";
export default function Home() {
return (
<div className="min-h-screen flex flex-col items-center gap-4 justify-start mt-12">
<h1 className="font-lobster text-7xl p-4 bg-cover bg-clip-text bg-gradient-to-r from-[#8A79FF] to-[#F093FF] text-transparent">
Candy Road
</h1>
<LoginButton />
</div>
);
}
If all goes as planned, you should be able to run npm run dev
in the terminal and see your button and login. That was easy! It’s really all we need for now, as we’ll guard the API endpoints with the auth soon.
Creating Frames
With our user authentication setup, it’s time to start making some frames. Let’s update the page.tsx
real quick with some future code we’re about to write:
import { LoginButton } from "@/components/login-button";
import { FrameGrid } from "@/components/frame-grid";
export default function Home() {
return (
<div className="min-h-screen flex flex-col items-center gap-4 justify-start mt-12">
<h1>Candy Road</h1>
<LoginButton />
<FrameGrid />
</div>
);
}
We just imported a new component called <FrameGrid />
so let’s build that now by creating a new component called frame-grid.tsx
and paste in this code:
"use client";
import { usePrivy } from "@privy-io/react-auth";
import { useEffect, useState } from "react";
import { CreateFrame } from "./create-frame";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import type { FileListItem } from "pinata";
import { ClipboardCopyIcon, CheckIcon } from "@radix-ui/react-icons";
import Link from "next/link";
export function FrameGrid() {
const [frames, setFrames] = useState([]);
const { authenticated, user, getAccessToken } = usePrivy();
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const wait = () => new Promise((resolve) => setTimeout(resolve, 1000));
async function handleCopy(frameId: string) {
setCopiedStates((prev) => ({ ...prev, [frameId]: true }));
await wait();
setCopiedStates((prev) => ({ ...prev, [frameId]: false }));
}
async function copyToClipboard(content: string, frameId: string) {
navigator.clipboard
.writeText(content)
.then(async () => await handleCopy(frameId))
.catch(() => alert("Failed to copy"));
}
async function getFrames() {
try {
const token = await getAccessToken();
const fileReq = await fetch(`/api/files?userId=${user?.id}`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
const files = await fileReq.json();
console.log(files);
setFrames(files.files);
} catch (error) {
console.log(error);
}
}
useEffect(() => {
if (!authenticated || !user) {
return;
}
async function getFrames() {
try {
const token = await getAccessToken();
const fileReq = await fetch(`/api/files?userId=${user?.id}`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
const files = await fileReq.json();
console.log(files);
setFrames(files.files);
} catch (error) {
console.log(error);
}
}
getFrames();
}, [authenticated, user, getAccessToken]);
if (!authenticated && !user) {
return null;
}
return (
<div className="flex flex-col gap-12">
<CreateFrame getFrames={getFrames} />
<div className="flex flex-col gap-4">
{frames &&
frames.length > 0 &&
frames.map((frame: FileListItem) => (
<Card className="max-w-[400px] overflow-hidden" key={frame.id}>
<div className="flex justify-between items-center px-2">
<p className="text-2xl font-bold p-2">{frame.name}</p>
<div className="flex items-center gap-2">
<Button className="bg-[#472A91]" size="icon" asChild>
<Link
className="h-4 w-4"
href={`https://warpcast.com/~/compose?embeds[]=https://www.candyroad.cloud/api/frame/${frame.cid}`}
target="_blank"
>
<svg
width="32"
height="32"
viewBox="0 0 1260 1260"
fill="none"
xmlns="<http://www.w3.org/2000/svg>"
>
<title>Warpcast Logo</title>
<g clip-path="url(#clip0_1_2)">
<path
d="M947.747 1259.61H311.861C139.901 1259.61 0 1119.72 0 947.752V311.871C0 139.907 139.901 0.00541362 311.861 0.00541362H947.747C1119.71 0.00541362 1259.61 139.907 1259.61 311.871V947.752C1259.61 1119.72 1119.71 1259.61 947.747 1259.61Z"
fill="#472A91"
/>
<path
d="M826.513 398.633L764.404 631.889L702.093 398.633H558.697L495.789 633.607L433.087 398.633H269.764L421.528 914.36H562.431L629.807 674.876L697.181 914.36H838.388L989.819 398.633H826.513Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_1_2">
<rect
width="1259.61"
height="1259.61"
fill="white"
/>
</clipPath>
</defs>
</svg>
</Link>
</Button>
<Button
variant="outline"
type="submit"
size="icon"
onClick={() =>
copyToClipboard(
`https://www.candyroad.cloud/api/frame/${frame.cid}`,
frame.id,
)
}
>
{copiedStates[frame.id] ? (
<CheckIcon className="h-4 w-4" />
) : (
<ClipboardCopyIcon className="h-4 w-4" />
)}
</Button>
</div>
</div>
<img
className="aspect-video object-cover"
src={frame.keyvalues.image}
alt={frame.name as string}
/>
</Card>
))}
</div>
</div>
);
}
There’s quite a bit going on here, so let’s walk through each piece. In general, this component will display a feed of all the frames we’ve already created. As you’ll see soon, each frame is a JSON file with all the details needed to construct the frame when rendering. When the JSON files are uploaded to Pinata, we’ll attach some metadata with the Privy user_id
of the user that created it. This will make it easy for us to:
- Fetch all the files for a specific user_id
- List them in the feed
- Allow users to copy a link to the frame or share in Warpcast.
When a user logs in, we’ll implement useEffect
to fetch the files from an API endpoint /api/files
, and we’ll authenticate the request using Privy’s getToken
from the user session.
async function getFrames() {
try {
const token = await getAccessToken();
const fileReq = await fetch(`/api/files?userId=${user?.id}`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
const files = await fileReq.json();
console.log(files);
setFrames(files.files);
} catch (error) {
console.log(error);
}
Now let’s go ahead and build that endpoint by creating a new folder in the app
directory called api
, another folder nested in there called files
, and finally a file in there called route.ts
.
import { NextResponse, type NextRequest } from "next/server";
import { pinata } from "@/utils/pinata";
import { verifySession } from "@/utils/session";
export async function GET(request: NextRequest) {
const authToken =
request?.headers?.get("authorization")?.replace("Bearer ", "") || "";
const verified = await verifySession(authToken);
if (!verified) {
return new NextResponse("Unauthorized", { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const userId = searchParams.get("userId");
if (!userId) {
return NextResponse.json(
{ text: "Must provide userId query" },
{ status: 400 },
);
}
try {
const files = await pinata.files.list().metadata({
userId: userId,
});
return NextResponse.json(files);
} catch (error) {
console.log(error);
return NextResponse.json({ text: "Error fetching files" }, { status: 500 });
}
}
This endpoint will grab the authentication token and use our utility code snippet from earlier to verifySession
. If the session is not valid, we’ll return a 401
. Otherwise, we’ll use the SDK to list all files with the metadata filter of userId
and send the results back to the client!
Overall, a pretty smooth flow. Now the only problem right now is that we don’t have any frames. We have a component imported called <CreateFrame />
, which will do this for us, so let’s create that now. Create a new file called create-frame.tsx
in components
and put in the following code:
"use client";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { usePrivy } from "@privy-io/react-auth";
import { pinata } from "@/utils/pinata";
import { ReloadIcon } from "@radix-ui/react-icons";
export function CreateFrame({ getFrames }: any) {
const [image, setImage] = useState<File>();
const [file, setFile] = useState<File>();
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const { getAccessToken, user } = usePrivy();
const userAddress = user?.wallet?.address;
const formSchema = z.object({
price: z.string(),
address: z.string().min(42, {
message: "Address cannot be empty",
}),
name: z.string(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
address: userAddress ? userAddress : "",
},
});
const imageHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
setImage(e.target?.files?.[0]);
};
const fileHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
setFile(e.target?.files?.[0]);
};
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const token = await getAccessToken();
if (!file || !image) {
alert("Select a file");
return;
}
if (!user?.id) {
alert("Issue with login");
return;
}
try {
const keyRequest = await fetch("/api/key", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
const keyData = await keyRequest.json();
const { cid: fileCid } = await pinata.upload.file(file).key(keyData.JWT);
const { cid: imageCid } = await pinata.upload
.file(image)
.key(keyData.JWT)
.group("0192868e-6144-7685-9fc5-af68a1e48f29");
const data = JSON.stringify({
name: values.name,
image: `https://${process.env.NEXT_PUBLIC_GATEWAY_URL}/files/${imageCid}`,
file: fileCid,
address: values.address,
price: values.price,
userId: user?.id,
});
const createFrameRequest = await fetch("/api/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: data,
});
const createdFrame = await createFrameRequest.json();
console.log(createdFrame);
setLoading(false);
setOpen(false);
form.reset();
await getFrames();
} catch (error) {
console.log(error);
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create Frame</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogTitle>Create a Frame</DialogTitle>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="My Frame" {...field} />
</FormControl>
<FormDescription>Name of your frame</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>Cover Image</FormLabel>
<Input type="file" onChange={imageHandle} />
<FormDescription>
Image preview that will show in the frame
</FormDescription>
</FormItem>
<FormItem>
<FormLabel>File</FormLabel>
<Input type="file" onChange={fileHandle} />
<FormDescription>File you want to sell</FormDescription>
</FormItem>
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Price</FormLabel>
<FormControl>
<Input placeholder="0.005" {...field} />
</FormControl>
<FormDescription>Price of the file in Eth</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="address"
render={({ field }) => (
<FormItem>
<FormLabel>Address</FormLabel>
<FormControl>
<Input
className="font-mono"
placeholder="0x..."
{...field}
/>
</FormControl>
<FormDescription>
Address of the wallet you want to be paid at
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{loading ? (
<Button disabled>
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
Creating Frame...
</Button>
) : (
<Button type="submit">Submit</Button>
)}
</form>
</Form>
</DialogContent>
</Dialog>
);
}
Once again, we’ve got a lot going on here, but in reality, it’s mostly UI imports and the UI itself. Functionally, the flow is pretty easy to grasp. To start, we have a button which will pop up a dialog with a form for the user to fill out. This form includes the name of the frame, a cover image, the file the user wants to sell, the price in ETH, and the wallet address that will receive the funds. When the user clicks ‘Submit,’ it will kick of the onSubmit
function where all the action happens.
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const token = await getAccessToken();
if (!file || !image) {
alert("Select a file");
return;
}
if (!user?.id) {
alert("Issue with login");
return;
}
try {
const keyRequest = await fetch("/api/key", {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
},
});
const keyData = await keyRequest.json();
const { cid: fileCid } = await pinata.upload.file(file).key(keyData.JWT);
const { cid: imageCid } = await pinata.upload
.file(image)
.key(keyData.JWT)
.group("0192868e-6144-7685-9fc5-af68a1e48f29");
const data = JSON.stringify({
name: values.name,
image: `https://${process.env.NEXT_PUBLIC_GATEWAY_URL}/files/${imageCid}`,
file: fileCid,
address: values.address,
price: values.price,
userId: user?.id,
});
const createFrameRequest = await fetch("/api/create", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: data,
});
const createdFrame = await createFrameRequest.json();
console.log(createdFrame);
setLoading(false);
setOpen(false);
form.reset();
await getFrames();
} catch (error) {
console.log(error);
setLoading(false);
}
}
Let’s look a little closer here to see what’s happening. First, it’s going to check and make sure that the files and fields are filled out, otherwise it will throw an error. After that check, it will make an API request to get a temporary API key for Pinata uploads. We do this because we want to be able to upload files larger than 4mb, and to do that we need to upload client side; however we don’t want to expose our admin API key. Instead we’ll create a temporary use API key just for the uploads. We’ll need to make that API route just like we did for /api/files
, so let’s do that now, except the path with be app/api/key/route.ts
:
import { type NextRequest, NextResponse } from "next/server";
import { pinata } from "@/utils/pinata";
import { verifySession } from "@/utils/session";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const authToken =
req?.headers?.get("authorization")?.replace("Bearer ", "") || "";
const verified = await verifySession(authToken);
if (!verified) {
return new Response("Unauthorized", { status: 401 });
}
try {
const uuid = crypto.randomUUID();
const keyData = await pinata.keys.create({
keyName: uuid.toString(),
permissions: {
endpoints: {
pinning: {
pinFileToIPFS: true,
},
},
},
maxUses: 2,
});
return NextResponse.json(keyData, { status: 200 });
} catch (error) {
console.log(error);
return NextResponse.json(
{ text: "Error creating API Key:" },
{ status: 500 },
);
}
}
This will also verify that the user has a valid session token before creating a key and sending it back down to the client. Once we have that key back in our function, we can upload our two files, with our cover image being uploaded to our public group to make sure the image is accessible. Then, we’ll take the results, along with the values from the form, build a JSON payload containing all the information regarding our frame, and send it to another API route /api/create
. Let’s build that out just like before, and put in the code below.
import { NextResponse, type NextRequest } from "next/server";
import { pinata } from "@/utils/pinata";
import { verifySession } from "@/utils/session";
export async function POST(request: NextRequest) {
const authToken =
request?.headers?.get("authorization")?.replace("Bearer ", "") || "";
const verified = await verifySession(authToken);
if (!verified) {
return new Response("Unauthorized", { status: 401 });
}
try {
const body = await request.json();
const json = await pinata.upload
.json({
name: body.name,
file: body.file,
image: body.image,
price: body.price,
address: body.address,
userId: body.userId,
})
.addMetadata({
name: body.name,
});
await pinata.files.update({
id: json.id,
keyvalues: {
userId: body.userId,
image: body.image,
},
});
return NextResponse.json(json.cid, { status: 200 });
} catch (e) {
console.log(e);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
Once again, we verify the session to make sure the user is authenticated, then we use pinata.upload.json()
with all of our frame data, as well as using the name of the file to indicate the name of the frame. We’ll do one more API call to update the file with some keyvalues
, such as our userId
and another reference to the image
link which our frame grid can use to easily display the images. Then we can send back the CID of the JSON upload back to the client.
With that, our app has a fully functional authentication and frame building creation, but at the moment our frame CIDs mean nothing. We need to build a method to render them.
Rendering Frames
To actually render our frames on Farcaster, we’ll use Frog, as it can easily integrate with an existing Next.js app. Create a new API route with the pattern app/api/[[...routes]]/route.tsx
and put in the following code:
/** @jsxImportSource frog/jsx */
import { Button, Frog, parseEther } from "frog";
import { handle } from "frog/next";
import { pinata } from "@/utils/pinata";
import { createClient } from "@/utils/supabase/server";
const app = new Frog({
basePath: "/api/frame",
title: "CandyRoad",
});
type FrameCID = {
name: string;
file: string;
image: string;
price: string;
address: string;
userId: string;
};
app.frame("/:cid", async (c) => {
const cid = c.req.param("cid");
const { data } = await pinata.gateways.get(cid);
const frameInfo = data as unknown as FrameCID;
return c.res({
title: frameInfo.name,
action: `/complete/${cid}`,
image: frameInfo?.image,
intents: [
<Button.Transaction target={`/purchase/${cid}`}>
Buy
</Button.Transaction>,
<Button action={`/redeem/${cid}`}>
Redeem
</Button>,
],
});
});
app.transaction("/purchase/:cid", async (c) => {
const cid = c.req.param("cid");
const { data } = await pinata.gateways.get(cid);
const frameInfo = data as unknown as FrameCID;
return c.send({
chainId: "eip155:84532",
to: frameInfo.address as `0x`,
value: parseEther(frameInfo?.price),
});
});
app.frame("/complete/:cid", async (c) => {
const cid = c.req.param("cid");
const supabase = createClient();
const { transactionId } = c;
if (!transactionId) {
return c.res({
action: `/complete/${cid}`,
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Transaction incomplete
</div>
),
intents: [
<Button.Transaction target={`/purchase/${cid}`}>
Buy
</Button.Transaction>,
],
});
}
const { data: insertRes, error } = await supabase
.from("purchases")
.insert([
{
buyer_id: c.frameData?.fid,
cid: c.req.param("cid"),
},
])
.select();
console.log(insertRes);
if (error) {
return c.res({
action: `/complete/${c.req.param("cid")}`,
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Error adding record
</div>
),
intents: [
<Button.Transaction target={`/purchase/${cid}`}>
Buy
</Button.Transaction>,
],
});
}
const dcReq = await fetch(
"<https://api.warpcast.com/v2/ext-send-direct-cast>",
{
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.WARPCAST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
recipientFid: c.frameData?.fid,
message: `Thank you for using CandyRoad! Your content can be downloaded any time with the frame attached to this message. <https://www.candyroad.cloud/api/frame/${cid}`>,
idempotencyKey: crypto.randomUUID().toString(),
}),
},
);
if (!dcReq.ok) {
const dcRes = await dcReq.json();
console.log(dcRes);
}
return c.res({
action: `/redeem/${cid}`,
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Transaction Complete!
</div>
),
intents: [
<Button action={`/redeem/${cid}`}>
Redeem File
</Button>,
],
});
});
app.frame("/redeem/:cid", async (c) => {
const cid = c.req.param("cid");
const supabase = createClient();
const { data } = await pinata.gateways.get(cid);
const frameInfo = data as unknown as FrameCID;
const fileUrl = await pinata.gateways.createSignedURL({
cid: frameInfo.file,
expires: 45,
});
const { data: rows, error } = await supabase
.from("purchases")
.select("*")
.eq("buyer_id", c.frameData?.fid)
.eq("cid", cid);
console.log(rows);
if (error || rows.length === 0) {
return c.res({
action: `/complete/${cid}`,
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Unauthorized
</div>
),
intents: [
<Button.Transaction target={`/purchase/${cid}`}>
Buy
</Button.Transaction>,
],
});
}
return c.res({
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Authorized! Download file
</div>
),
intents: [
<Button.Link href={fileUrl}>
Download File
</Button.Link>,
],
});
});
export const GET = handle(app);
export const POST = handle(app);
Let’s break down each piece to understand what’s going on here.
app.frame("/:cid", async (c) => {
const cid = c.req.param("cid");
const { data } = await pinata.gateways.get(cid);
const frameInfo = data as unknown as FrameCID;
return c.res({
title: frameInfo.name,
action: `/complete/${cid}`,
image: frameInfo?.image,
intents: [
<Button.Transaction target={`/purchase/${cid}`}>
Buy
</Button.Transaction>,
<Button action={`/redeem/${cid}`}>
Redeem
</Button>,
],
});
});
This is our initial frame that takes in the cid
as a path parameter, and then pulls the JSON data using pinata.gateways.get
. We can then pass this data throughout the response, particularly the name
of the frame and the cover image
. We’re also going to create a few buttons for the user to press. The first one is a <Button.Transaction>
, which will go to /purchase/${cid}
in order to initiate the purchase. On complete, it will go to /complete/${cid}
. The other button will be a regular action button to visit the /redeem/${cid}
, just in case the file has already been purchased and the user wants to download it.
Let’s move to the purchase
route next:
app.transaction("/purchase/:cid", async (c) => {
const cid = c.req.param("cid");
const { data } = await pinata.gateways.get(cid);
const frameInfo = data as unknown as FrameCID;
return c.send({
chainId: "eip155:84532",
to: frameInfo.address as `0x`,
value: parseEther(frameInfo?.price),
});
});
This one is actually really simple, as it will also get the frameInfo
just as before, create a transaction on Base for the listed price, and send the amount to the recipient address. For this app, we just use a simple transfer, but you could absolutely implement a custom smart contract here for more control. As mentioned earlier, the route will go to /complete
after the transaction finishes:
app.frame("/complete/:cid", async (c) => {
const cid = c.req.param("cid");
const supabase = createClient();
const { transactionId } = c;
if (!transactionId) {
return c.res({
action: `/complete/${cid}`,
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Transaction incomplete
</div>
),
intents: [
<Button.Transaction target={`/purchase/${cid}`}>
Buy
</Button.Transaction>,
],
});
}
const { data: insertRes, error } = await supabase
.from("purchases")
.insert([
{
buyer_id: c.frameData?.fid,
cid: c.req.param("cid"),
},
])
.select();
console.log(insertRes);
if (error) {
return c.res({
action: `/complete/${c.req.param("cid")}`,
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Error adding record
</div>
),
intents: [
<Button.Transaction target={`/purchase/${cid}`}>
Buy
</Button.Transaction>,
],
});
}
const dcReq = await fetch(
"<https://api.warpcast.com/v2/ext-send-direct-cast>",
{
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.WARPCAST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
recipientFid: c.frameData?.fid,
message: `Thank you for using CandyRoad! Your content can be downloaded any time with the frame attached to this message. <https://www.candyroad.cloud/api/frame/${cid}`>,
idempotencyKey: crypto.randomUUID().toString(),
}),
},
);
if (!dcReq.ok) {
const dcRes = await dcReq.json();
console.log(dcRes);
}
return c.res({
action: `/redeem/${cid}`,
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Transaction Complete!
</div>
),
intents: [
<Button action={`/redeem/${cid}`}>
Redeem File
</Button>,
],
});
});
We do have quite a bit going on here as we have to check if the transaction was successful and plan some responses based on possible outcomes. If successful, then we’ll insert a row into our Supabase database with the FID of the buyer, as well as the CID. We’ll use this data in a bit for our /redeem
route. We’ll also send the Warpcast Direct Cast with a link to the frame to the user, dynamically passing in our frame cid
into the URL. After that, we’ll send a response with an option to redeem the file.
app.frame("/redeem/:cid", async (c) => {
const cid = c.req.param("cid");
const supabase = createClient();
const { data } = await pinata.gateways.get(cid);
const frameInfo = data as unknown as FrameCID;
const fileUrl = await pinata.gateways.createSignedURL({
cid: frameInfo.file,
expires: 45,
});
const { data: rows, error } = await supabase
.from("purchases")
.select("*")
.eq("buyer_id", c.frameData?.fid)
.eq("cid", cid);
console.log(rows);
if (error || rows.length === 0) {
return c.res({
action: `/complete/${cid}`,
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Unauthorized
</div>
),
intents: [
<Button.Transaction target={`/purchase/${cid}`}>
Buy
</Button.Transaction>,
],
});
}
return c.res({
image: (
<div style={{ color: "white", display: "flex", fontSize: 60 }}>
Authorized! Download file
</div>
),
intents: [
<Button.Link href={fileUrl}>
Download File
</Button.Link>,
],
});
});
The /redeem
is where the magic happens. We’ll take the cid
from the path argument, get the frameInfo
, and create a fileUrl
which will be how someone accesses the private file. Then we use our database to do a check if the user has actually bought the file. If they have, then we’ll provide a button link to download the file in their browser!
Wrapping Up
It’s been a long road, but you did it! You built a cryptonative Gumroad on Base that takes advantage of the Farcaster social graph. It doesn’t have to stop here — what if you expanded it beyond Farcaster? Privy will give you the ability to expand your auth abilities, and the flow to unlock private files with Pinata is already setup! Just a matter of tweaking the flows and how people access it.
This is just one possibility out of many with the Files API; sign up today and experience it for yourself!