Render a Feed in a Farcaster App using React

Our goal is to render a nice feed of casts (posts on Farcaster) for a specific user.

We can use the embed SDK to get the feeds and then render them! The SDK already handles error retries and more, so we can just get a feed!

To not expose API Keys we will call the Embed API from the server (think API routes in case you use NextJS).

From there we will forward that to our Frontend and render the feed.

The overall architecture of that looks as follows with our Frontend asking to get a feed, the server calling the embed API nicely via the SDK and the feed being passed back as response from the server to the frontend.

You can clone our mini app sample that provides a mini app rendering a feed here on Github or run the following command to set it up on your computer.

bunx degit https://github.com/ZKAI-Network/embed-sdk/examples/miniapp-monorepo embed-miniapp

Now cd embed-miniapp, install dependencies bun install and run the mini app locally bun dev. Make sure you have set the environment variables.

By filling in environment variables and setting up the project you should now see a feed of Farcaster posts on your screen.

But how did we get there? How to implement this yourself?

The following guide will use tRPC for type-safe interaction between client and server with hono serving the API while Vite + React is used as client-side framework. Though you can take the SDK code and handle the calls yourself in any framework you want.

In NextJS you'd go and create an API Route that calls the SDK and returns the feed. Just like we're going to do with tRPC. Copy and pasting this whole page into an LLM to use AI assisted coding can get that done for you too. There is a Ask AI and Copy Markdown button top right of this page on desktop.

1. Get the Farcaster Feed from the Embed API

We'll use tRPC on the server-side implementation to create a type-safe API that securely handles the Embed API calls for us. As the API Key is a secret we don't want to expose, we only have it in the server environment variables and not the client package! Here's how it works:

We'll initialize the context with our API Key and then use the SDK (lines 22-32) to return the feed.

tRPC allows us to share this method in a typesafe manner between the client and server.

// packages/shared/src/index.ts
import { initTRPC } from "@trpc/server"
import { getClient } from "@embed-ai/sdk"
import { z } from "zod"
import { ALL_FEED_IDS } from "../constants/feedIds.js"

const t = initTRPC.context<{ API_KEY_EMBED?: string }>().create()

export const appRouter = t.router({
  forYouFeed: t.procedure
    .input(
      z.object({
        fid: z.number(),
        feed_id: z.enum(ALL_FEED_IDS as [string, ...Array<string>]).optional()
      })
    )
    .query(async ({ ctx, input }) => {
      if (!ctx.API_KEY_EMBED) {
        throw new Error("API_KEY_EMBED is not configured on the server.")
      }

      const client = getClient(ctx.API_KEY_EMBED)
      const options = {
        top_k: 10,
        ...(input.feed_id && { feed_id: input.feed_id })
      }
      
      const feed = await client.feed.byUserId(
        String(input.fid),
        options
      )
      return feed
    })
})

Now on the server side we can handle requests in Hono.

Notice how we pass the API_KEY_EMBED which we got from the Console as environment variable to be consumed by the SDK which we used to get the feed just before in the tRPC code.

On the server we're exposing that code to be used in our application.

// packages/server/src/index.ts
import { trpcServer } from "@hono/trpc-server"
import { appRouter } from "@shared/trpc"
import { Hono } from "hono"
import { cors } from "hono/cors"

const app = new Hono()

app.use("*", cors({
  origin: "*",
  credentials: true,
  allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
  allowHeaders: ["Content-Type", "Authorization", "x-trpc-source"]
}))

app.use("/trpc/*", trpcServer({
  router: appRouter,
  createContext: () => ({
    API_KEY_EMBED: process.env.API_KEY_EMBED
  }),
  onError: ({ error, path }) => {
    console.error(`tRPC Error on path: ${path}`, error)
  }
}))

export default {
  port: 3000,
  fetch: app.fetch
}

With the ability to get a feed from the server that calls the Embed API for the Farcaster data via the Embed SDK we can now render the Farcaster Feed in our application.

2. Render the Farcaster Feed in our App

On the client-side we need to call our server we implemented with Hono and tRPC for type-safe API calls and then render the feed we get back from the Embed SDK.

The client-side implementation should include several important features relevant to our user experience like:

  1. Infinite Scrolling: Automatic pagination with intersection observer
  2. Pull-to-Refresh: Native mobile-like refresh functionality
  3. Rich Embeds: Support for images, videos, URLs, and location data
  4. Interactive Actions: Reply, share, and tip functionality if we develop a Mini App especially
  5. Error Handling: Comprehensive error states and loading indicators
  6. Responsive Design: Mobile-first design with Tailwind CSS

Luckily the example already has it all and we'll walk through how it works so you can follow along to implement it in React yourself. Remember NextJS uses React, so you can follow along in NextJS too! Though we'll showcase the Vite + React frontend from the example mini app.

Setup tRPC on the Frontend to use our server side Embed SDK Implementation

In your frontend client make sure to setup tRPC

// packages/client/src/trpc.ts
import type { AppRouter } from "@shared/trpc"
import { createTRPCReact } from "@trpc/react-query"

export const trpc = createTRPCReact<AppRouter>()

Lets now create the Providers

What’s happening here is that trpc.createClient sets up the tRPC client with a link to the server’s tRPC endpoint (e.g., http://127.0.0.1:3000/trpc). While the QueryClient from React Query manages caching and refetching.

The trpc.Provider and QueryClientProvider make the tRPC client and query client available to all components. If you are using NextJS, you can set up tRPC in a similar way but use NextJS’s App component or a custom provider.

// packages/client/src/main.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { trpc } from "./trpc"
import { httpBatchLink } from "@trpc/client"

function Root() {
  const [queryClient] = useState(() => new QueryClient())
  const [trpcClient] = useState(() =>
    trpc.createClient({
      links: [
        httpBatchLink({
          url: (import.meta.env.VITE_API_URL || "http://127.0.0.1:3000") + "/trpc",
        }),
      ],
    })
  )

  return (
    <trpc.Provider client={trpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </trpc.Provider>
  )
}

To get the data it makes sense to get first setup a Hook we can reuse across components in case we decide to add more feed components.

To fetch the feed data and manage state (e.g., pagination, loading, errors), create a custom hook called useFeedData. This hook centralizes all data-related logic, making it easy to reuse in different components.

  • A custom hook keeps your data-fetching logic simple by not repeating yourself and makes it easier to manage complex features like pagination and error handling.
  • The hook uses tRPC to call the forYouFeed endpoint, manages pagination, and handles user-specific data (e.g., the user’s Farcaster ID, or FID).

Here’s the hook implementation from our sample mini app:

// packages/client/src/hooks/useFeedData.ts
export function useFeedData(options: { fetchDefault?: boolean; feedId?: string } = {}) {
  const { feedId, fetchDefault = true } = options
  const { context, isRunningOnFrame, isSDKLoaded } = useFrame()
  const [pages, setPages] = useState<Array<any>>([])
  const [customFid, setCustomFid] = useState<number>()

  const fidToUse = customFid ?? 
    (fetchDefault ? (isRunningOnFrame && context?.user?.fid) || 3 : undefined)

  const {
    data: forYouData,
    error: forYouError,
    isLoading: forYouLoading,
    refetch: triggerQuery
  } = trpc.forYouFeed.useQuery(
    { fid: fidToUse!, ...(feedId && { feed_id: feedId }) },
    { enabled: !!fidToUse && isSDKLoaded && pages.length === 0 }
  )

  // Handle pagination and data management
  const fetchNextPage = useCallback(async () => {
    if (isFetchingNextPage || !hasNextPage || !fidToUse) return
    
    setIsFetchingNextPage(true)
    try {
      const nextPageData = await triggerQuery()
      if (nextPageData.data) {
        setPages((prevPages) => [...prevPages, nextPageData.data])
      }
    } catch (e) {
      console.error("Failed to fetch next page", e)
    } finally {
      setIsFetchingNextPage(false)
    }
  }, [isFetchingNextPage, hasNextPage, triggerQuery, fidToUse])

  const flattenedData = pages.flatMap((page) =>
    (page.body as Array<any>).map((item) => ({
      ...item,
      metadata: {
        ...item.metadata,
        author: {
          ...item.metadata.author,
          fid: item.metadata.author.user_id
        }
      }
    }))
  )

  return {
    data: flattenedData,
    isLoading: forYouLoading && pages.length === 0,
    error: forYouError,
    fidToUse,
    setFid: setCustomFid,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage,
    refetch: triggerQuery
  }
}

The hook takes optional parameters (fetchDefault and feedId) to control whether to fetch a default feed and which feed type to use.

It uses useFrame (a custom hook for Farcaster Mini Apps) to check if the app is running in a Farcaster frame and get the user’s FID, that we've built for our sample here.. If no custom FID is set, it falls back to the user’s FID (if available) or a default FID.

The trpc.forYouFeed.useQuery call fetches the feed data, but only runs if fidToUse exists, the SDK is loaded, and no pages are loaded yet.

Pagination is handled by storing pages in state (pages) and providing a fetchNextPage function to load more data. The flattenedData transforms the paginated data into a flat array of posts for rendering.

If you are not building a Farcaster Mini App, remove the useFrame logic and set a default FID or allow users to input one, depending on what state or user management system you have in place in your app.

Frontend components for our Feed

Now, create components to render the feed. The main components are:

  • FeedGrid: Displays the list of posts, handles infinite scrolling, and shows loading/error states.
  • FeedCard: Renders an individual post with author info, content, embeds, and engagement metrics.
  • HomePage: Orchestrates the entire UI, including headers, user profiles, and feed selection.

The FeedGrid component is the container for the feed, responsible for rendering posts and managing infinite scrolling.

It uses the useInView hook to detect when the user scrolls to the bottom, triggering fetchNextPage which implements infinite-scroll.

The following is the implementation in our sample mini app:

// packages/client/src/components/FeedGrid.tsx
export function FeedGrid({
  title,
  data,
  isLoading,
  error,
  fetchNextPage,
  isFetchingNextPage,
  hasNextPage,
  onRefresh,
  isRefreshing,
}: FeedGridProps) {
  const { ref, inView } = useInView({ threshold: 0 })

  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage()
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage])

  return (
    <div className="space-y-4">
      <div className="flex justify-between items-center">
        <h3 className="text-lg text-muted-foreground">{title}</h3>
        <Button onClick={onRefresh} disabled={isRefreshing} variant="outline" size="sm">
          {isRefreshing && <Loader size="sm" className="mr-2" />}
          Refresh
        </Button>
      </div>

      {error && <ErrorState message={error.message} />}
      
      {isLoading && (
        <div className="grid grid-cols-1 gap-4">
          {Array.from({ length: 8 }).map((_, index) => (
            <LoadingSkeleton key={index} />
          ))}
        </div>
      )}

      {data && data.length > 0 && (
        <div className="grid grid-cols-1 gap-4">
          {data.map((item) => (
            <FeedCard key={item.item_id} item={item} />
          ))}
        </div>
      )}

      {hasNextPage && !isFetchingNextPage && <div ref={ref} />}
      
      {isFetchingNextPage && (
        <div className="grid grid-cols-1 gap-4">
          {Array.from({ length: 4 }).map((_, index) => (
            <LoadingSkeleton key={index} />
          ))}
        </div>
      )}

      {data && data.length === 0 && !isLoading && <EmptyState />}
    </div>
  )
}

The FeedCard component renders an individual post, including the author’s avatar, content, embeds, and engagement metrics.

The component receives an item (a single post) and extracts metadata like the author, text, and engagement counts. It uses useFrame to access Farcaster-specific actions like composeCast (for replying or sharing).
The UI includes an avatar, post content, embedded media (via ImageGallery and UrlEmbed), and engagement icons (comments, shares, likes). You can customize the card layout or styling to match your app’s design system.

The following is the implementation in our sample mini app

// packages/client/src/components/FeedCard.tsx
export function FeedCard({ item }: FeedCardProps) {
  const { author, text, comments_count, shares_count, likes_count, embed_items } = item.metadata
  const { actions: { composeCast, viewProfile, sendToken } } = useFrame()

  const handleShare = () => {
    composeCast({
      text: `I found this interesting cast from @${author.username} in the Embed Mini App`,
      embeds: [`https://warpcast.com/${author.username}/${item.item_id}`],
    })
  }

  const handleReply = () => {
    composeCast({
      text: "Interesting! By the way, I found your cast on Embed Mini App #shamelessPlug",
      parent: { type: "cast", hash: item.item_id },
    })
  }

  return (
    <Card className="border rounded-lg shadow-sm h-full">
      <CardContent className="p-6 flex flex-col h-full space-y-4">
        {/* Author Info */}
        <div className="flex items-center gap-3 cursor-pointer" onClick={handleViewProfile}>
          <Avatar className="w-12 h-12 ring-1 ring-border">
            <AvatarImage src={author.pfp_url} alt={author.display_name} />
            <AvatarFallback className="bg-primary/10 text-primary font-medium">
              {author.display_name ? author.display_name.charAt(0).toUpperCase() : <IconUser size={20} />}
            </AvatarFallback>
          </Avatar>
          <div className="flex-1 min-w-0">
            <p className="font-semibold text-sm line-clamp-1">{author.display_name}</p>
            <p className="text-muted-foreground text-xs">@{author.username}</p>
          </div>
        </div>

        {/* Content */}
        <p className="text-sm flex-1 leading-6">{text}</p>

        {/* Embeds */}
        {embed_items && embed_items.length > 0 && (
          <div className="space-y-3 pt-2">
            {/* Handle images, videos, URLs, and location embeds */}
            <ImageGallery images={images} className="w-full" />
            {otherEmbeds.map((embed, index) => (
              <UrlEmbed key={index} url={embed} />
            ))}
          </div>
        )}

        {/* Engagement Stats */}
        <div className="flex justify-between items-center mt-auto pt-4">
          <div className="flex items-center gap-1 cursor-pointer" onClick={handleReply}>
            <IconMessageCircle size={14} color="#2563eb" />
            <span className="text-xs text-muted-foreground">{comments_count || 0}</span>
          </div>
          <div className="flex items-center gap-1">
            <IconRepeat size={14} color="#16a34a" />
            <span className="text-xs text-muted-foreground">{shares_count || 0}</span>
          </div>
          <div className="flex items-center gap-1">
            <IconHeart size={14} color="#dc2626" />
            <span className="text-xs text-muted-foreground">{likes_count || 0}</span>
          </div>
          <div className="flex items-center gap-1 cursor-pointer" onClick={handleShare}>
            <IconShare size={14} color="#6b7280" />
          </div>
        </div>
      </CardContent>
    </Card>
  )
}

The HomePage component ties everything together, providing the main UI with headers, user profiles, feed selection, and the feed itself.

The component receives props from the useFeedData hook and other state (e.g., selectedFeed).
It renders a header, a user profile (if running in a Farcaster frame), a feed selection dropdown, and the FeedGrid.
The handleRefresh function triggers a refetch of the feed data.

The main app orchestrates everything and provides the user interface. The following implementation is straight from our mini app sample.

// packages/client/src/pages/HomePage.tsx
export function HomePage(props: HomePageProps) {
  const {
    data,
    isLoading,
    error,
    timestamp,
    isRunningOnFrame,
    isSDKLoaded,
    userInfo,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage,
    refetch,
    selectedFeed,
    setSelectedFeed,
  } = props

  const handleRefresh = async () => {
    await refetch()
  }

  return (
    <div className="max-w-7xl mx-auto px-4 py-8">
      <div className="space-y-8">
        {/* Header */}
        <FeedHeader timestamp={timestamp} />
        
        {/* User Profile Section */}
        {isRunningOnFrame && userInfo && (
          <div className="flex items-center gap-4 p-4 border border-border rounded-lg bg-card">
            <Avatar className="w-16 h-16 ring-2 ring-border">
              <AvatarImage src={userInfo.pfpUrl} alt={userInfo.displayName || userInfo.username} />
              <AvatarFallback className="bg-primary/10 text-primary">
                <IconUser size={24} />
              </AvatarFallback>
            </Avatar>
            <div>
              <h2 className="font-medium text-lg">{userInfo.displayName || userInfo.username}</h2>
              <p className="text-sm text-muted-foreground">@{userInfo.username} • FID: {userInfo.fid}</p>
            </div>
          </div>
        )}

        {/* Feed Selection */}
        <div className="space-y-2">
          <label className="text-sm font-medium">Select a feed</label>
          <Select value={selectedFeed} onValueChange={(value) => setSelectedFeed(value as FeedId)}>
            <SelectTrigger>
              <SelectValue placeholder="Pick a feed" />
            </SelectTrigger>
            <SelectContent>
              {FEEDS.map((feed) => (
                <SelectItem key={feed.id} value={feed.id}>
                  {feed.name}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>

        {/* Feed Content */}
        {isSDKLoaded && (
          <FeedGrid
            title={isRunningOnFrame && userInfo ? `For you, @${userInfo.username}`: "For you (demo)"}
            data={data}
            isLoading={isLoading}
            error={error}
            fetchNextPage={fetchNextPage}
            isFetchingNextPage={isFetchingNextPage}
            hasNextPage={hasNextPage}
            onRefresh={handleRefresh}
            isRefreshing={isDataRefreshing}
          />
        )}
      </div>
    </div>
  )
}

Now before running the app make sure you have setup your environment variables, which are expected to be:

# Server (.env file in packages/server/)
API_KEY_EMBED=your_embed_api_key_here

# Client (.env file in packages/client/)
VITE_API_URL=http://127.0.0.1:3000

In case your app is not a Farcaster Mini App already you can easily convert it following this guide . The example mini app on Github is already setup to be a mini app.

You can clone our mini app sample that provides a mini app rendering a feed here on Github or run the following command to set it up on your computer.

bunx degit https://github.com/ZKAI-Network/embed-sdk/examples/miniapp-monorepo embed-miniapp

Now cd embed-miniapp, install dependencies bun install and run the mini app locally bun dev. Make sure you have set the environment variables.

Congratulations! You have now setup your own app with a custom Farcaster Feed or jump started your project by cloning the sample.