Develop a Zora Feed App with NextJS and Base MiniKit

A few steps to create a personalized Zora feed where users can buy social media posts.

Onchain content feeds are here!

In April 2025, Zora upgraded its protocol to let creators mint any piece of content as a tradable ERC-20 token. Shortly after, Base adopted this model in its new Everything App—every social post is now automatically minted on Zora. ❜embed makes it dead-simple to build and embed personalized on-chain content feeds into your application. In this guide, we’ll walk through how to spin up a Next.js mini-app that displays a Farcaster feed via ❜embed, detects Zora coin embeds, and lets users buy tokens directly in-feed. Let’s dive in!

Set up

Clone the sample and deploy your own feed right away.

bunx degit https://github.com/ZKAI-Network/embed-sdk/examples/minikit-zora-feed-nextjs embed-miniapp
cd embed-miniapp
bun install
cp .env.example .env.local
echo "I need to fill in .env variables"
bun run dev

In case you don't have an embed API Key get it at the Getting started - your first feed guide.
A Zora API key can be created in their developer settigns, see Zora Docs

Now you can adjust the look and feel or adjust functionality. When setting environment variables and deploying it will look something like:

How to do it yourself, developing a NextJS Mini App with a Zora Feed

First we'll setup a fresh NextJS app already initialized with Base MiniKit to work in The Base App and Farcaster as well as standalone on the web with Zora coin integrations.

bunx create-onchain --mini

Now you can enter your project name and leave the Coinbase Developer Platform Client API Key empty, we don't need it here.

Our values after running the above starter look like

✔ Project name: … minikit-zora-feed-nextjs
✔ Enter your Coinbase Developer Platform Client API Key: (optional) … 
✔ Share anonymous usage data to help improve create-onchain? … no / yes
✔ Creating minikit-zora-feed-nextjs...

we can now go into the directory, install dependencies and run dev to see what the starter looks like.

cd minikit-zora-feed-nextjs
bun install
bun run dev

You should now see something along the lines of

From here we will adjust this page and make it render a Zora Feed using the @embed-ai/react package from the SDK.

To use the endpoints make sure you have an API Key, if not click here.

Integrate Feeds into MiniKit with @embed-ai/react

First things first we want to show feeds, which we can do using the React and Typescript SDK from embed.

Now in NextJS we will create an API Route that serves the Feed using @embed-ai/sdk while our frontend will be setup to use that feed and render it using @embed-ai/react which includes UI components and React Hooks.

Setup an API route in NextJS to serve Zora Feeds with the @embed-ai/sdk

MiniKit's template has setup a few API routes regarding Mini Apps already. Let's add the one serving the feed for us.

We'll make it fully customizable so we can pick which feed we want and which user is viewing it. The fid is important so the feed is personalized for the viewer! We'll use the Mini App context for that on the frontend side.

Here is the code from our example app implementing the API endpoint proxying feeds for us so the API Key doesn't get exposed on the frontend. It's basically a call to the client.feed.byUserId() function and a code to make sure it errors and handles expected inputs properly.

import { NextRequest, NextResponse } from "next/server";
import { getClient } from "@embed-ai/sdk";

// Initialize client only when needed to avoid build-time errors
let client: ReturnType<typeof getClient> | null = null;

function getEmbedClient() {
  if (!client) {
    if (!process.env.API_KEY_EMBED) {
      throw new Error("API_KEY_EMBED environment variable is not set");
    }
    client = getClient(process.env.API_KEY_EMBED);
  }
  return client;
}

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const fid = searchParams.get("fid");
    const feedId = searchParams.get("feed_id");

    ...
    // Call the Embed AI SDK for feed data
    const embedClient = getEmbedClient();
    const feedData = await embedClient.feed.byUserId(fid, feedId || undefined, {
      top_k: 10,
    });

    return NextResponse.json(feedData);
  } catch (error) {
    console.error("Feed API error:", error);
    return NextResponse.json(
      { error: "Failed to fetch feed data" },
      { status: 500 }
    );
  }
}

Frontend integration of the backend data via hooks

To render feeds we can make use of the batteries included @embed-ai/react package.

Now that we have a get endpoint to query for a feed we will write a hook to fetch that data for us.

We will pass that to the React components so they use the SDK on the backend for fetching feeds we can render.

Let's create app/hooks/useFeedData.ts:

import { useCallback, useEffect, useState } from "react";
import { useMiniKit } from "@coinbase/onchainkit/minikit";
import type { FeedItem } from "@embed-ai/react/feed";

export interface UseFeedDataReturn {
  data?: Array<FeedItem>;
  isLoading: boolean;
  error?: { message: string } | null;
  fidToUse?: number;
  customFid?: number;
  setFid: (fid?: number) => void;
  timestamp: string;
  isRunningOnFrame: boolean;
  userInfo?: {
    fid: number;
    displayName?: string;
    username?: string;
    pfpUrl?: string;
  };
  fetchNextPage: () => void;
  isFetchingNextPage: boolean;
  hasNextPage: boolean;
  refetch: () => Promise<void>;
  isRefreshing: boolean;
}

export function useFeedData(
  options: {
    fetchDefault?: boolean;
    feedId?: string;
  } = {}
): UseFeedDataReturn {
  const { feedId, fetchDefault = true } = options;
  const { context, isFrameReady } = useMiniKit();
  const [data, setData] = useState<Array<FeedItem>>();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<{ message: string } | null>(null);
  const [timestamp, setTimestamp] = useState("");
  const [isFetchingNextPage, setIsFetchingNextPage] = useState(false);
  const [hasNextPage, setHasNextPage] = useState(true);
  const [customFid, setCustomFid] = useState<number>();
  const [isRefreshing, setIsRefreshing] = useState(false);

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

  const fetchFeedData = useCallback(async (fid: number, feedId?: string) => {
    try {
      const response = await fetch(`/api/feed?fid=${fid}${feedId ? `&feed_id=${feedId}` : ''}`);
      
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      const result = await response.json();
      
      if (result.error) {
        throw new Error(result.error);
      }
      
      return result;
    } catch (error) {
      console.error('Failed to fetch feed data:', error);
      throw error;
    }
  }, []);

  const setFid = useCallback((fid?: number) => {
    setCustomFid(fid);
    setData(undefined);
    setHasNextPage(true);
  }, []);

  const refetch = useCallback(async () => {
    if (!fidToUse) return;
    
    setIsRefreshing(true);
    setError(null);
    
    try {
      const result = await fetchFeedData(fidToUse, feedId);
      setData(result);
      setTimestamp(new Date().toLocaleTimeString());
      setHasNextPage(result.length > 0);
    } catch (err) {
      setError({ message: err instanceof Error ? err.message : 'Failed to fetch feed data' });
    } finally {
      setIsRefreshing(false);
    }
  }, [fidToUse, feedId, fetchFeedData]);

  const fetchNextPage = useCallback(async () => {
    if (isFetchingNextPage || !hasNextPage || !fidToUse) return;

    setIsFetchingNextPage(true);
    try {
      const nextPageData = await fetchFeedData(fidToUse, feedId);
      if (nextPageData.length === 0) {
        setHasNextPage(false);
      } else {
        setData(prev => prev ? [...prev, ...nextPageData] : nextPageData);
      }
    } catch (err) {
      console.error("Failed to fetch next page", err);
    } finally {
      setIsFetchingNextPage(false);
    }
  }, [isFetchingNextPage, hasNextPage, fidToUse, feedId, fetchFeedData]);

  // Initial fetch
  useEffect(() => {
    if (fidToUse && !data && !isLoading) {
      setIsLoading(true);
      setError(null);
      
      fetchFeedData(fidToUse, feedId)
        .then(result => {
          setData(result);
          setTimestamp(new Date().toLocaleTimeString());
          setHasNextPage(result.length > 0);
        })
        .catch(err => {
          setError({ message: err instanceof Error ? err.message : 'Failed to fetch feed data' });
        })
        .finally(() => {
          setIsLoading(false);
        });
    }
  }, [fidToUse, feedId, data, isLoading, fetchFeedData]);

  return {
    data,
    isLoading,
    error,
    fidToUse,
    customFid,
    setFid,
    timestamp,
    isRunningOnFrame,
    userInfo: context?.user
      ? {
          fid: context.user.fid,
          displayName: context.user.displayName,
          username: context.user.username,
          pfpUrl: context.user.pfpUrl,
        }
      : undefined,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage,
    refetch,
    isRefreshing,
  };
}

With the hook present we can use it to develop our Frontend. This is where we now use the data we gathered via the hook from the SDK implemented in our API to render Feeds.

Notice how we are using the context from the Mini App to personalize for the user. the fidToUse is set based on which FID is viewing the app. On Web when there is no Mini App context we fall back to FID 3 here, though you could let a user on web Sign in With Farcaster and use that FID or let them input theirs.

Frontend Integration with ready-made components to render our Farcaster & Zora content feed

First we'll create a FeedContainer. This Container holds all our feed user interfaces and we stay flexible to overwrite the provided components with custom styles. To get a Zora Buy button in the Card rendering each post we'll do this exactly that! It shows how you can simply extend the provided components to fit your application.

Let's create app/components/FeedContainer.tsx:

"use client";

import { useInView } from "react-intersection-observer";
import { useEffect } from "react";
import { FeedGrid, type FeedItem } from "@embed-ai/react/feed";
import { useFeedData } from "../hooks/useFeedData";
import { useOpenUrl } from "@coinbase/onchainkit/minikit";
import { CustomFeedCard } from "./CustomFeedCard";

Notice on the imports how we're getting a FeedGrid right out of the box with the @embed-ai/react package and use our useFeedData hook to get the data for it.

interface FeedContainerProps {
  title?: string;
  feedId?: string;
}

export function FeedContainer({ title = "Your Feed", feedId }: FeedContainerProps) {
  const {
    data,
    isLoading,
    error,
    fetchNextPage,
    isFetchingNextPage,
    hasNextPage,
    refetch,
    isRefreshing,
  } = useFeedData({ feedId });

  const { ref, inView } = useInView({
    threshold: 0,
  });

  const openUrl = useOpenUrl();

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

  const isEmpty = !data || data.length === 0;

  // MiniKit actions
  const handleShare = (item: FeedItem) => {
    const { author } = item.metadata;
    const url = `https://farcaster.xyz/${author.username}/${item.item_id}`;
    
    // Use openUrl to share for now - in a real app you'd use compose cast
    openUrl(url);
  };

  const handleReply = (item: FeedItem) => {
    const url = `https://farcaster.xyz/${item.metadata.author.username}/${item.item_id}`;
    openUrl(url);
  };

  const handleViewProfile = (item: FeedItem) => {
    const { author } = item.metadata;
    const url = `https://farcaster.xyz/${author.username}`;
    openUrl(url);
  };

  const handleTip = (item: FeedItem) => {
    // For now, just open the profile - in a real app you'd use sendToken
    const { author } = item.metadata;
    const url = `https://farcaster.xyz/${author.username}`;
    openUrl(url);
  };

  return (
    <div className="w-full max-w-full overflow-hidden feed-container">
      <FeedGrid
        title={title}
        isLoading={isLoading}
        error={error}
        isFetchingNextPage={isFetchingNextPage}
        hasNextPage={hasNextPage}
        onRefresh={refetch}
        isRefreshing={isRefreshing}
        loaderRef={ref}
        isEmpty={isEmpty}
      >
      {data &&
        data.map((item) => (
          <CustomFeedCard
            key={item.item_id}
            item={item}
            onShare={() => handleShare(item)}
            onReply={() => handleReply(item)}
            onViewProfile={() => handleViewProfile(item)}
            onTip={() => handleTip(item)}
          />
        ))}
      </FeedGrid>
    </div>
  );
}

The provided FeedGrid component of the @embed-ai/react package makes it easy for us. we provide the function handlers of what action should be done on interactions and can overwrite with our CustomFeedCard where we get Zora Buys and more!

Lets define how each Post should be rendered with the app/components/CustomFeedCard.tsx component.

There'll be some pieces missing from this component that we will build out after, though it's important to see the context and how easy it plugs into the FeedGrid.

Create app/components/CustomFeedCard.tsx:

"use client";

import { FeedCard, type FeedItem } from "@embed-ai/react/feed";
import { ZoraEmbedRenderer } from "./ZoraEmbedRenderer";
import { isZoraTokenUrl } from "../utils/zora";

interface CustomFeedCardProps {
  item: FeedItem;
  onShare?: () => void;
  onReply?: () => void;
  onViewProfile?: () => void;
  onTip?: () => void;
}

/**
 * Custom FeedCard wrapper that processes Zora embeds
 * This component integrates Zora buy components directly into the FeedCard structure
 */
export function CustomFeedCard({ item, ...props }: CustomFeedCardProps) {
  // Check if this item has any Zora embeds
  const hasZoraEmbeds = item.metadata.embed_items?.some(embed => isZoraTokenUrl(embed));
  
  // If no Zora embeds, use original FeedCard
  if (!hasZoraEmbeds) {
    return (
      <div 
        className="w-full max-w-full overflow-hidden [&>*]:max-w-full [&>*]:overflow-hidden [&_img]:max-w-full [&_img]:h-auto [&_video]:max-w-full [&_video]:h-auto [&_iframe]:max-w-full [&_iframe]:h-auto [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_a]:break-all [&_p]:break-words [&>*]:box-border"
        style={{ maxWidth: '100%', boxSizing: 'border-box', overflow: 'hidden' }}
      >
        <FeedCard item={item} {...props} />
      </div>
    );
  }
  
  // Process the item to handle Zora embeds
  const processedItem: FeedItem = {
    ...item,
    metadata: {
      ...item.metadata,
      // We'll render Zora embeds separately, so filter them out from embed_items
      embed_items: item.metadata.embed_items?.filter(embed => !isZoraTokenUrl(embed)),
    }
  };
  
  // Extract Zora embeds for separate rendering
  const zoraEmbeds = item.metadata.embed_items?.filter(embed => isZoraTokenUrl(embed)) || [];
  
  return (
    <div className="w-full max-w-full overflow-hidden" style={{ maxWidth: '100%', boxSizing: 'border-box' }}>
      {/* Custom FeedCard with integrated Zora components */}
      <div 
        className="w-full max-w-full overflow-hidden [&>*]:max-w-full [&>*]:overflow-hidden [&_img]:max-w-full [&_img]:h-auto [&_video]:max-w-full [&_video]:h-auto [&_iframe]:max-w-full [&_iframe]:h-auto [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_a]:break-all [&_p]:break-words [&>*]:box-border"
        style={{ maxWidth: '100%', boxSizing: 'border-box', overflow: 'hidden' }}
      >
        <FeedCardWithZoraBuy item={processedItem} zoraEmbeds={zoraEmbeds} {...props} />
      </div>
    </div>
  );
}

interface FeedCardWithZoraBuyProps {
  item: FeedItem;
  zoraEmbeds: string[];
  onShare?: () => void;
  onReply?: () => void;
  onViewProfile?: () => void;
  onTip?: () => void;
}

/**
 * Custom component that renders FeedCard with Zora Buy components integrated
 * This creates a seamless appearance by styling the Buy component to look like part of the FeedCard
 */
function FeedCardWithZoraBuy({ item, zoraEmbeds, ...props }: FeedCardWithZoraBuyProps) {
  return (
    <div className="feed-card-with-zora-container">
      {/* Render the original FeedCard */}
      <div className="feed-card-wrapper">
        <FeedCard item={item} {...props} />
      </div>
      
      {/* Render Zora Buy components styled to appear as part of the FeedCard */}
      {zoraEmbeds.length > 0 && (
        <div className="zora-buy-extension">
          {zoraEmbeds.map((embed, index) => (
            <div key={`zora-${index}`} className="w-full max-w-full">
              <ZoraEmbedRenderer embed={embed} />
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Notice how we leverage FeedCard from the react package and extend it with our own Zora Renderer which will handle the buying of tokens. Lovely!

Now that we have the feed setup you can already run the app and see your first feeds when plugging in the custom FeedContainer into a page. Though we want to integrate Zora too on top of the Farcaster content recommended from Embed.

Adding Zora support to buy tokens straight from our Farcaster Feed

To integrate zora we will develop our custom ZoraEmbedRenderer that shows up if the embed on the Farcaster post contains a Zora Coin. To do so we'll setup helper functions.

The first will provide is general zora utility's that we'll use to parse Zora URLs, check which chains we support and more. It's set up to be extended easily.

Let's create app/utils/zora.ts

/**
 * Utility functions for parsing and handling Zora URLs
 */

export interface ZoraTokenInfo {
  chain: string;
  chainId: number;
  contractAddress: string;
  referrer?: string;
}

/**
 * Chain name to chain ID mapping
 */
const CHAIN_MAP: Record<string, number> = {
  'base': 8453,
  'ethereum': 1,
  'optimism': 10,
  'arbitrum': 42161,
  'polygon': 137,
  'zora': 7777777,
};

/**
 * Parse a Zora URL to extract token information
 * Supports both formats:
 * - https://zora.co/coin/base:0x47f9cec54d9bc2014cb0e6fa58f54e0b222176c2?referrer=0xb3bf9649116e3c00bfc1919b037f8c12b2cb197b
 * - zoracoin://0x19433ed1feeecd0cd973ac73526732faaaf21dca
 * - zoraCoin://0x19433ed1feeecd0cd973ac73526732faaaf21dca (case insensitive)
 */
export function parseZoraUrl(url: string): ZoraTokenInfo | null {
  try {
    // Handle zoracoin:// and zoraCoin:// protocol formats
    if (url.toLowerCase().startsWith('zoracoin://')) {
      const contractAddress = url.replace(/^zoracoin:\/\//i, '');
      
      // Validate contract address (basic ethereum address format)
      if (!/^0x[a-fA-F0-9]{40}$/.test(contractAddress)) {
        return null;
      }
      
      // Default to Base chain for zoracoin:// format
      return {
        chain: 'base',
        chainId: 8453,
        contractAddress: contractAddress.toLowerCase(),
      };
    }
    
    // Handle https://zora.co/coin/ format
    const urlObj = new URL(url);
    
    // Check if it's a Zora URL
    if (urlObj.hostname !== 'zora.co') {
      return null;
    }
    
    // Check if it's a coin path
    const pathMatch = urlObj.pathname.match(/^\/coin\/([^:]+):(.+)$/);
    if (!pathMatch) {
      return null;
    }
    
    const [, chain, contractAddress] = pathMatch;
    
    // Validate chain
    const chainId = CHAIN_MAP[chain.toLowerCase()];
    if (!chainId) {
      return null;
    }
    
    // Validate contract address (basic ethereum address format)
    if (!/^0x[a-fA-F0-9]{40}$/.test(contractAddress)) {
      return null;
    }
    
    // Extract referrer if present
    const referrer = urlObj.searchParams.get('referrer');
    
    return {
      chain: chain.toLowerCase(),
      chainId,
      contractAddress: contractAddress.toLowerCase(),
      referrer: referrer || undefined,
    };
  } catch (error) {
    console.error('Error parsing Zora URL:', error);
    return null;
  }
}

/**
 * Check if a URL is a Zora token URL
 */
export function isZoraTokenUrl(url: string): boolean {
  return parseZoraUrl(url) !== null;
}

/**
 * Check if a chain is supported for trading
 * Currently focusing on Base (8453) as mentioned in requirements
 */
export function isSupportedChain(chainId: number): boolean {
  // For now, only support Base
  return chainId === 8453;
}

/**
 * Get display name for chain
 */
export function getChainDisplayName(chainId: number): string {
  const chainNames: Record<number, string> = {
    1: 'Ethereum',
    8453: 'Base',
    10: 'Optimism',
    42161: 'Arbitrum',
    137: 'Polygon',
    7777777: 'Zora',
  };
  
  return chainNames[chainId] || `Chain ${chainId}`;
}

See the parseZoraUrl function checking zoraCoin and zora.co links.

Next we'll need to get metadata about Zora Coins. We want to render rich information to the user and allow you to customize that as well, so let's use the Zora SDK to get the metadata.

A simple sample wihout any fees and passing through the trade looks like the following app/utils/tradeCoinWithFee.ts:

import { TradeParameters, tradeCoin } from "@zoralabs/coins-sdk";
import { WalletClient, PublicClient, Account } from "viem";

interface TradeCoinWithFeeParams {
  tradeParameters: TradeParameters;
  walletClient: WalletClient;
  account: Account;
  publicClient: PublicClient;
}

export async function tradeCoinWithFee({
  tradeParameters,
  walletClient,
  account,
  publicClient,
}: TradeCoinWithFeeParams) {
  // Execute the trade using Zora SDK
  const result = await tradeCoin({
    ...tradeParameters,
    walletClient,
    account,
    publicClient,
  });

  return result;
}

Though we want to take a fee and showcase how composable feeds are to provide earning potential to you as developer.

To bundle the transaction of buying coins and have the option to collect a Fee we'll use MultiCall and in our sample take a 2% fee.

The calling and bundling logic will live in app/utils/traedCoinWithFee.ts and use the zora coin sdk:

import { createTradeCall, TradeParameters } from "@zoralabs/coins-sdk";
import { WalletClient, PublicClient, Account, Address, encodeFunctionData } from "viem";

const FEE_RECIPIENT = "YOUR WALLET" as const;
const FEE_PERCENTAGE = 2; // 2%

// Base mainnet multicall3 contract address
const MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11" as const;

// Multicall3 ABI for aggregateValue function (supports ETH transfers)
const MULTICALL3_ABI = [
  {
    inputs: [
      {
        components: [
          { name: "target", type: "address" },
          { name: "allowFailure", type: "bool" },
          { name: "value", type: "uint256" },
          { name: "callData", type: "bytes" }
        ],
        name: "calls",
        type: "tuple[]"
      }
    ],
    name: "aggregate3Value",
    outputs: [
      {
        components: [
          { name: "success", type: "bool" },
          { name: "returnData", type: "bytes" }
        ],
        name: "returnData",
        type: "tuple[]"
      }
    ],
    stateMutability: "payable",
    type: "function"
  }
] as const;

interface TradeCoinWithFeeParams {
  tradeParameters: TradeParameters;
  walletClient: WalletClient;
  account: Account;
  publicClient: PublicClient;
  validateTransaction?: boolean;
}

export async function tradeCoinWithFee({
  tradeParameters,
  walletClient,
  account,
  publicClient,
}: TradeCoinWithFeeParams) {
  // Calculate 2% fee from the input amount
  const feeAmount = (tradeParameters.amountIn * BigInt(FEE_PERCENTAGE)) / BigInt(100);

  // Adjust the trade amount to account for the fee
  const adjustedTradeAmount = tradeParameters.amountIn - feeAmount;

  // Create adjusted trade parameters
  const adjustedTradeParameters: TradeParameters = {
    ...tradeParameters,
    amountIn: adjustedTradeAmount,
  };

  // Get the trade call data using Zora SDK
  const tradeQuote = await createTradeCall(adjustedTradeParameters);

  // Prepare multicall calls with values for ETH transfers
  const calls = [
    {
      target: tradeQuote.call.target as Address,
      allowFailure: false,
      value: BigInt(tradeQuote.call.value),
      callData: tradeQuote.call.data as `0x${string}`,
    },
    {
      target: FEE_RECIPIENT,
      allowFailure: false,
      value: feeAmount,
      callData: "0x" as `0x${string}`, // Empty calldata for ETH transfer
    }
  ];

  // Encode the multicall function call
  const multicallData = encodeFunctionData({
    abi: MULTICALL3_ABI,
    functionName: "aggregate3Value",
    args: [calls],
  });

  // Execute the multicall transaction
  const txHash = await walletClient.sendTransaction({
    account,
    to: MULTICALL3_ADDRESS,
    data: multicallData,
    value: BigInt(tradeQuote.call.value) + feeAmount, // Total ETH needed
    chain: undefined,
  });

  // Wait for transaction receipt
  const receipt = await publicClient.waitForTransactionReceipt({
    hash: txHash,
  });

  return receipt;
}

With all utilities setup, we return to developing the user interface using these. With a hook to get Token Metadata we'll provide it to our frontends.

The hook can be implemented as follows:

import { useEffect, useState } from 'react';
import type { Token } from '@coinbase/onchainkit/token';
import { getCoin, setApiKey } from '@zoralabs/coins-sdk';

export interface UseTokenFromContractResult {
  token: Token | null;
  isLoading: boolean;
  error: string | null;
}

/**
 * Hook to fetch token metadata from a contract address using the Zora SDK
 */
export function useTokenFromContract(
  contractAddress: string,
  chainId: number
): UseTokenFromContractResult {
  const [token, setToken] = useState<Token | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!contractAddress || !chainId) {
      setToken(null);
      setIsLoading(false);
      setError(null);
      return;
    }

    // Only support Base chain for now
    if (chainId !== 8453) {
      setError('Unsupported chain');
      setIsLoading(false);
      return;
    }

    const fetchTokenMetadata = async () => {
      setIsLoading(true);
      setError(null);
      
      try {
        // Set API key if available
        if (process.env.NEXT_PUBLIC_ZORA_API_KEY) {
          setApiKey(process.env.NEXT_PUBLIC_ZORA_API_KEY);
        }
        
        // Use Zora SDK to get real token metadata
        const response = await getCoin({
          address: contractAddress,
          chain: chainId,
        });

        if (response.data?.zora20Token) {
          const zoraToken = response.data.zora20Token;
          
          // Convert Zora token data to OnchainKit Token format
          const token: Token = {
            name: zoraToken.name || 'Unknown Token',
            address: contractAddress as `0x${string}`,
            symbol: zoraToken.symbol || 'UNKNOWN',
            decimals: 18, // Zora tokens are typically 18 decimals
            image: '',
            chainId: chainId,
          };

          setToken(token);
        } else {
          // Fallback if no data from Zora SDK
          const fallbackToken: Token = {
            name: 'Unknown Token',
            address: contractAddress as `0x${string}`,
            symbol: 'UNKNOWN',
            decimals: 18,
            image: '',
            chainId: chainId,
          };
          
          setToken(fallbackToken);
        }
      } catch (err) {
        console.error('Error fetching token metadata from Zora SDK:', err);
        
        // Fallback to basic token info on error
        const fallbackToken: Token = {
          name: 'Token',
          address: contractAddress as `0x${string}`,
          symbol: 'TOKEN',
          decimals: 18,
          image: '',
          chainId: chainId,
        };
        
        setToken(fallbackToken);
        setError('Failed to fetch token metadata');
      } finally {
        setIsLoading(false);
      }
    };

    fetchTokenMetadata();
  }, [contractAddress, chainId]);

  return { token, isLoading, error };
}

Now truely onto frontend integrations.

Remember the CustomFeedCard we developed? We added a ZoraEmbedRenderer to buy coins to it, though we haven't yet developed that. First we want the Coin buying user interface and then render the Embed with Metadata.

The goal will be to extend the card from

to this which shows the buy option from our custom zora embed.

Let's create the app/components/ZoraBuyComponent.tsx. We can directly use the utility function we developed to get the transaction and we'll sprinkle in some fun confetti animations on success too.

To showcase the user that the transaction passed and notify of failure we'll handle these cases. Especially since money is at stake here it is important to inspire confidence.

The following is the implementation in our Example repository.

"use client";

import { useState } from "react";
import { parseEther } from "viem";
import { useAccount, useWalletClient, usePublicClient } from "wagmi";
import { TradeParameters } from "@zoralabs/coins-sdk";
import { tradeCoinWithFee } from "../utils/tradeCoinWithFee";
import Image from "next/image";
import confetti from "canvas-confetti";

interface ZoraBuyComponentProps {
  tokenAddress: string;
  tokenName: string;
  tokenSymbol: string;
  tokenImage?: string;
  chainId: number;
}

export function ZoraBuyComponent({
  tokenAddress,
  tokenName,
  tokenSymbol,
  tokenImage,
}: ZoraBuyComponentProps) {
  const { address: account } = useAccount();
  const { data: walletClient } = useWalletClient();
  const publicClient = usePublicClient();
  
  const [amount, setAmount] = useState("0.001");
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState(false);
  const [txHash, setTxHash] = useState<string | null>(null);

  const triggerConfetti = () => {
    const duration = 3 * 1000;
    const animationEnd = Date.now() + duration;
    const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };

    function randomInRange(min: number, max: number) {
      return Math.random() * (max - min) + min;
    }

    const interval = setInterval(() => {
      const timeLeft = animationEnd - Date.now();

      if (timeLeft <= 0) {
        clearInterval(interval);
        return;
      }

      const particleCount = 50 * (timeLeft / duration);
      
      // since particles fall down, start a bit higher than random
      confetti({
        ...defaults,
        particleCount,
        origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
      });
      confetti({
        ...defaults,
        particleCount,
        origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
      });
    }, 250);
  };

  const handleBuy = async () => {
    if (!account || !walletClient || !publicClient) {
      setError("Please connect your wallet");
      return;
    }

    if (!amount || parseFloat(amount) <= 0) {
      setError("Please enter a valid amount");
      return;
    }

    setIsLoading(true);
    setError(null);
    setSuccess(false);
    setTxHash(null);

    try {
      const tradeParameters: TradeParameters = {
        sell: { type: "eth" },
        buy: {
          type: "erc20",
          address: tokenAddress as `0x${string}`,
        },
        amountIn: parseEther(amount),
        slippage: 0.05, // 5% slippage tolerance
        sender: account,
      };

      const receipt = await tradeCoinWithFee({
        tradeParameters,
        walletClient,
        account: walletClient.account,
        publicClient,
      });

      if (receipt.status === "success") {
        setSuccess(true);
        setTxHash(receipt.transactionHash);
        setAmount("0.001"); // Reset amount
        triggerConfetti(); // Trigger confetti on success
      } else {
        setError("Transaction failed");
      }
    } catch (err) {
      console.error("Error buying token:", err);
      // Handle common error scenarios
      if (err instanceof Error) {
        if (err.message.includes("insufficient funds")) {
          setError("Insufficient ETH balance");
        } else if (err.message.includes("user rejected")) {
          setError("Transaction cancelled by user");
        } else if (err.message.includes("slippage")) {
          setError("Price changed too much. Try again with higher slippage");
        } else {
          setError(err.message);
        }
      } else {
        setError("Transaction failed");
      }
    } finally {
      setIsLoading(false);
    }
  };

  if (!account) {
    return (
      <div className="bg-[var(--app-gray-light)] rounded-lg p-4 text-center">
        <p className="text-sm text-[var(--app-foreground-muted)]">
          Connect your wallet to buy {tokenSymbol}
        </p>
      </div>
    );
  }

  return (
    <div className="bg-[var(--app-gray-light)] rounded-lg p-4 space-y-3">
      <div className="flex items-center justify-between">
        {tokenImage && (
          <Image 
            src={tokenImage} 
            alt={tokenName}
            width={32}
            height={32}
            className="w-8 h-8 rounded-full"
          />
        )}
      </div>

      <div className="space-y-2">
        <div className="flex items-center space-x-2">
          <input
            type="number"
            value={amount}
            onChange={(e) => setAmount(e.target.value)}
            placeholder="0.001"
            step="0.001"
            min="0"
            className="flex-1 px-3 py-2 bg-[var(--app-background)] border border-[var(--app-border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-accent)] focus:border-transparent"
            disabled={isLoading}
          />
          <span className="text-sm text-[var(--app-foreground-muted)]">ETH</span>
        </div>

        <button
          onClick={handleBuy}
          disabled={isLoading || !amount || parseFloat(amount) <= 0}
          className="w-full bg-[var(--app-accent)] text-white py-2 px-4 rounded-md text-sm font-medium hover:bg-[var(--app-accent-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
        >
          {isLoading ? "Processing..." : `Buy`}
        </button>
      </div>

      {error && (
        <div className="text-red-500 text-xs p-2 bg-red-50 rounded border border-red-200">
          {error}
        </div>
      )}

      {success && (
        <div className="text-green-600 text-xs p-2 bg-green-50 rounded border border-green-200 space-y-1">
          <p>Successfully purchased {tokenSymbol}!</p>
          {txHash && (
            <a 
              href={`https://basescan.org/tx/${txHash}`}
              target="_blank"
              rel="noopener noreferrer"
              className="text-blue-600 hover:underline block"
            >
              View transaction →
            </a>
          )}
        </div>
      )}
    </div>
  );
}

The Buy component now handles the blockchain interaction on Base, what's missing is the integration with the Renderer so we can use it and for the Renderer to be there as it's used in our CustomFeedCard.

The Zora Embed Render we've developed is a simple component. You can expand this anytime. The metadata you already have with the hook!

What we've done in the renderer component is handle the failure case as well. Here's the implementation from the sample

"use client";

import { type ReactNode } from "react";
import Image from "next/image";
import { ZoraBuyComponent } from "./ZoraBuyComponent";
import { parseZoraUrl, isZoraTokenUrl, isSupportedChain, getChainDisplayName } from "../utils/zora";
import { useTokenFromContract } from "../hooks/useTokenFromContract";

interface ZoraEmbedRendererProps {
  embed: string;
  fallback?: ReactNode;
}

/**
 * Component that renders Zora token embeds with Buy components
 * Falls back to original link preview for non-Zora URLs
 */
export function ZoraEmbedRenderer({ embed, fallback }: ZoraEmbedRendererProps) {
  // Check if the embed is a Zora URL
  if (!isZoraTokenUrl(embed)) {
    return fallback || <DefaultEmbedFallback url={embed} />;
  }

  const zoraInfo = parseZoraUrl(embed);
  
  if (!zoraInfo) {
    return fallback || <DefaultEmbedFallback url={embed} />;
  }

  return <ZoraTokenEmbed zoraInfo={zoraInfo} originalUrl={embed} />;
}

interface ZoraTokenEmbedProps {
  zoraInfo: NonNullable<ReturnType<typeof parseZoraUrl>>;
  originalUrl: string;
}

function ZoraTokenEmbed({ zoraInfo, originalUrl }: ZoraTokenEmbedProps) {
  const { token, isLoading, error } = useTokenFromContract(
    zoraInfo.contractAddress,
    zoraInfo.chainId
  );

  // Check if chain is supported
  if (!isSupportedChain(zoraInfo.chainId)) {
    return (
      <UnsupportedChainMessage 
        chainName={getChainDisplayName(zoraInfo.chainId)}
        originalUrl={originalUrl}
      />
    );
  }

  // Loading state
  if (isLoading) {
    return <LoadingTokenEmbed />;
  }

  // Error state
  if (error || !token) {
    console.error("Error in ZoraEmbedRenderer", error)
    return <ErrorTokenEmbed error={error} originalUrl={originalUrl} />;
  }

  // Success state - render Buy component integrated into the feed card
  return (
    <div className="zora-embed-renderer space-y-3 max-w-full overflow-hidden">
      <div className="flex items-start space-x-3 min-w-0">
        {token.image && (
          <Image 
            src={token.image} 
            alt={"ZoraCoin"}
            width={32}
            height={32}
            className="w-8 h-8 rounded-full flex-shrink-0 mt-0.5"
          />
        )}
      </div>
      
      <div className="w-full max-w-full overflow-hidden">
        <ZoraBuyComponent
          tokenAddress={token.address}
          tokenName={token.name}
          tokenSymbol={token.symbol}
          tokenImage={token.image || undefined}
          chainId={token.chainId}
        />
      </div>
    </div>
  );
}

function LoadingTokenEmbed() {
  return (
    <div className="zora-embed-renderer space-y-3">
      <div className="flex items-center space-x-3">
        <div className="w-8 h-8 rounded-full bg-[var(--app-gray)] animate-pulse" />
        <div className="space-y-2">
          <div className="w-20 h-3 bg-[var(--app-gray)] rounded animate-pulse" />
          <div className="w-12 h-2 bg-[var(--app-gray)] rounded animate-pulse" />
        </div>
      </div>
      <div className="w-full h-10 bg-[var(--app-gray)] rounded animate-pulse" />
    </div>
  );
}

interface ErrorTokenEmbedProps {
  error: string | null;
  originalUrl: string;
}

function ErrorTokenEmbed({ error, originalUrl }: ErrorTokenEmbedProps) {
  return (
    <div className="zora-embed-renderer">
      <div className="space-y-2">
        <p className="text-xs text-red-500">
          Failed to load token: {error || 'Unknown error'}
        </p>
        <a
          href={originalUrl}
          target="_blank"
          rel="noopener noreferrer"
          className="text-[var(--app-accent)] hover:underline text-xs"
        >
          View on Zora →
        </a>
      </div>
    </div>
  );
}

interface UnsupportedChainMessageProps {
  chainName: string;
  originalUrl: string;
}

function UnsupportedChainMessage({ chainName, originalUrl }: UnsupportedChainMessageProps) {
  return (
    <div className="zora-embed-renderer">
      <div className="space-y-2">
        <p className="text-xs text-[var(--app-foreground-muted)]">
          Token trading not supported on {chainName}
        </p>
        <a
          href={originalUrl}
          target="_blank"
          rel="noopener noreferrer"
          className="text-[var(--app-accent)] hover:underline text-xs"
        >
          View on Zora →
        </a>
      </div>
    </div>
  );
}

interface DefaultEmbedFallbackProps {
  url: string;
}

function DefaultEmbedFallback({ url }: DefaultEmbedFallbackProps) {
  return (
    <div className="zora-embed-renderer">
      <a
        href={url}
        target="_blank"
        rel="noopener noreferrer"
        className="text-[var(--app-accent)] hover:underline text-xs break-all"
      >
        {url}
      </a>
    </div>
  );
}

You can clearly see how it builds on top of the work we've done before to procide the metadata, get data and develop the pieces of frontend to put together.

To ensure it correctly works with the wallet and zora SDK we've adapted the configuration of MiniKit in the app/providers.tsx file by providing our own wagmiConfig to be used with the ZoraSDK:

"use client";

import { type ReactNode } from "react";
import { base } from "wagmi/chains";
import { MiniKitProvider } from "@coinbase/onchainkit/minikit";
import { WagmiProvider, createConfig, http } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

// Create wagmi config for use with Zora SDK
const wagmiConfig = createConfig({
  chains: [base],
  transports: {
    [base.id]: http(),
  },
});

const queryClient = new QueryClient();

export function Providers(props: { children: ReactNode }) {
  return (
    <WagmiProvider config={wagmiConfig}>
      <QueryClientProvider client={queryClient}>
        <MiniKitProvider
          apiKey={process.env.NEXT_PUBLIC_ONCHAINKIT_API_KEY}
          projectId={process.env.NEXT_PUBLIC_CDP_PROJECT_ID}
          chain={base}
          //rpcUrl={process.env.NEXT_PUBLIC_RPC_URL}
          config={{
            analytics: false,
            appearance: {
              mode: "auto",
              theme: "mini-app-theme",
              name: process.env.NEXT_PUBLIC_ONCHAINKIT_PROJECT_NAME,
              logo: process.env.NEXT_PUBLIC_ICON_URL,
            },
          }}
        >
          {props.children}
        </MiniKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

You've now created a Feed with Zora integration! Try it out and buy your first coins based on Farcaster content.


Publishing the app and making sure it works as Mini App

In layout.tsx check the fc:frame config and update th values for buttons etc as you see fit.

Also make sure to have the .env variables set as they define your .well-known/farcaster.json which is what the clients show for the name, who built it and more.

To test you can follow the following guide and as soon as you verified it works as expected in a mini app share it with friends.