DEV Community

onyi
onyi

Posted on

From Zero to GenLayer: Connecting Your dApp with Next.js and genlayer-js (Part 3/3)

Your contract is deployed. It lives on the GenLayer network, it can create bets, accept them, and use AI to resolve subjective outcomes. You tested it in Studio, watched validators argue about whether a movie was "critically acclaimed," and saw the right answer win. That was satisfying.

But nobody is going to interact with your contract through a developer console. They need a frontend. So let us build one.


Project Setup

GenLayer provides an official boilerplate that gives you a full-stack project out of the box. Clone it:

git clone https://github.com/genlayerlabs/genlayer-project-boilerplate.git GenBets
cd GenBets
Enter fullscreen mode Exit fullscreen mode

Take a look at what you get:

GenBets/
  contracts/          # Python intelligent contracts
  frontend/           # Next.js 15 app (TypeScript, Tailwind, TanStack Query)
  deploy/             # TypeScript deployment scripts
  test/               # Python integration tests
Enter fullscreen mode Exit fullscreen mode

The boilerplate ships with a sample contract called football_bets.py. We do not need it. Replace it with the genbets.py contract you wrote in Part 2:

cp your-genbets-contract/genbets.py contracts/genbets.py
Enter fullscreen mode Exit fullscreen mode

Now set up the frontend:

cd frontend
npm install
Enter fullscreen mode Exit fullscreen mode

Copy the environment template and configure it:

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

Open .env and set the contract address you got from Studio when you deployed in Part 2:

NEXT_PUBLIC_GENLAYER_RPC_URL=https://studio.genlayer.com/api
NEXT_PUBLIC_GENLAYER_CHAIN_ID=61999
NEXT_PUBLIC_GENLAYER_CHAIN_NAME=GenLayer Studio
NEXT_PUBLIC_GENLAYER_SYMBOL=GEN
NEXT_PUBLIC_CONTRACT_ADDRESS=0xYourContractAddressFromStudio
Enter fullscreen mode Exit fullscreen mode

That last line is the important one. Without it, the frontend has no idea which contract to talk to.

The frontend stack is modern but not exotic: Next.js 15 with the App Router, React 19, TypeScript, Tailwind CSS for styling, TanStack Query for data management, and MetaMask wallet integration through genlayer-js. If you have built a React app before, nothing here will surprise you. The only new piece is genlayer-js -- GenLayer's JavaScript SDK for reading from and writing to intelligent contracts.


Understanding the Architecture

Before diving into code, it helps to understand how the pieces connect. The boilerplate separates concerns into four layers:

  1. Contract class (lib/contracts/GenBets.ts) -- A TypeScript wrapper around raw genlayer-js calls. This is where readContract() and writeContract() live.
  2. React hooks (lib/hooks/useGenBets.ts) -- Custom hooks that wrap the contract class with TanStack Query for caching, loading states, and automatic refetching.
  3. UI components (components/) -- The visible interface. Tables, modals, buttons, status badges.
  4. Wallet management (lib/genlayer/client.ts and WalletProvider.tsx) -- MetaMask connection, network switching, and account management.

The data flow for every user action follows the same path:

User clicks button
  -> React hook fires
    -> Contract class method called
      -> genlayer-js sends request to GenLayer RPC
        -> GenLayer network processes transaction
Enter fullscreen mode Exit fullscreen mode

The GenLayer client connects to the Studio RPC endpoint (or a local node) and delegates transaction signing to MetaMask. When you call writeContract(), MetaMask pops up asking the user to confirm. When you call readContract(), it is a free read -- no signature needed, no popup.

TanStack Query sits in the middle and handles the tedious parts: caching responses so you do not re-fetch data on every render, automatically refetching when the browser window regains focus, and invalidating stale data after a mutation succeeds.


The Contract Class

The GenBets.ts contract class is the bridge between your TypeScript frontend and your Python contract on GenLayer. Here is how it works.

The constructor creates a genlayer-js client configured with the chain, the user's wallet address, and the RPC endpoint:

import { createClient } from "genlayer-js";
import { studionet } from "genlayer-js/chains";

class GenBets {
  private contractAddress: `0x${string}`;
  private client: ReturnType<typeof createClient>;

  constructor(contractAddress: string, address?: string | null, studioUrl?: string) {
    this.contractAddress = contractAddress as `0x${string}`;

    const config: any = { chain: studionet };
    if (address) config.account = address as `0x${string}`;
    if (studioUrl) config.endpoint = studioUrl;

    this.client = createClient(config);
  }
}
Enter fullscreen mode Exit fullscreen mode

Two fundamental operations drive everything:

Reading state uses readContract(). This calls @gl.public.view methods on the contract. No gas, no wallet signature. The getBets() method demonstrates a common pattern -- calling get_bet_count first, then looping through get_bet(i) to build an array:

async getBets(): Promise<Bet[]> {
  const betCount: any = await this.client.readContract({
    address: this.contractAddress,
    functionName: "get_bet_count",
    args: [],
  });

  const count = Number(betCount);
  const bets: Bet[] = [];

  for (let i = 0; i < count; i++) {
    const betData: any = await this.client.readContract({
      address: this.contractAddress,
      functionName: "get_bet",
      args: [i],
    });
    bets.push({ id: i, ...betData } as Bet);
  }
  return bets;
}
Enter fullscreen mode Exit fullscreen mode

Writing state uses writeContract(). This calls @gl.public.write methods, which modify the blockchain. These require a wallet signature and cost gas. After sending the transaction, you wait for it to be accepted:

async createBet(description: string, resolutionUrl: string,
                resolutionPrompt: string, opponent: string): Promise<TransactionReceipt> {
  const txHash = await this.client.writeContract({
    address: this.contractAddress,
    functionName: "create_bet",
    args: [description, resolutionUrl, resolutionPrompt, opponent],
  });

  const receipt = await this.client.waitForTransactionReceipt({
    hash: txHash,
    status: "ACCEPTED",
    retries: 24,
    interval: 5000,
  });
  return receipt;
}
Enter fullscreen mode Exit fullscreen mode

That waitForTransactionReceipt() call is worth understanding. GenLayer transactions do not go from "pending" to "confirmed" like Ethereum. They move through a multi-stage consensus pipeline: PENDING, PROPOSING, COMMITTING, REVEALING, ACCEPTED, and finally FINALIZED. Each stage corresponds to a phase of the Optimistic Democracy protocol, where validators independently execute the contract, propose results, and vote on whether they agree. The status: "ACCEPTED" parameter tells the SDK to wait until validators have reached consensus and the transaction is accepted -- though not yet finalized.

Notice there is no value parameter in the writeContract() call. The GenBets contract does not handle token transfers -- it tracks who wins but does not move money. This keeps the contract simple and focused on the AI resolution logic.

The resolveBet() method follows the same pattern but returns the winner string from the transaction receipt, so the UI can display who won.


React Hooks

The hooks layer wraps each contract method with TanStack Query, turning raw async calls into reactive data that components can consume without thinking about loading states or cache invalidation.

The useBets() hook fetches all bets and keeps them fresh:

export function useBets() {
  const contract = useGenBetsContract();

  return useQuery<Bet[], Error>({
    queryKey: ["bets"],
    queryFn: () => contract ? contract.getBets() : Promise.resolve([]),
    refetchOnWindowFocus: true,
    staleTime: 2000,
    enabled: !!contract,
  });
}
Enter fullscreen mode Exit fullscreen mode

Any component that calls useBets() gets { data, isLoading, isError } -- the standard TanStack Query return shape. Multiple components can call the same hook and they will share the cached result rather than making duplicate network requests.

Mutations follow a different pattern. useCreateBet() wraps the contract's write method and handles cache invalidation on success:

export function useCreateBet() {
  const contract = useGenBetsContract();
  const queryClient = useQueryClient();
  const [isCreating, setIsCreating] = useState(false);

  const mutation = useMutation({
    mutationFn: async ({ description, resolutionUrl, resolutionPrompt,
                         opponent }) => {
      setIsCreating(true);
      return contract.createBet(description, resolutionUrl,
                                resolutionPrompt, opponent);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["bets"] });
      setIsCreating(false);
    },
  });

  return { ...mutation, isCreating, createBet: mutation.mutate };
}
Enter fullscreen mode Exit fullscreen mode

When a bet is created successfully, invalidateQueries({ queryKey: ["bets"] }) tells TanStack Query that the cached bets list is stale. Every component using useBets() automatically refetches.

The useResolveBet() hook adds one extra piece: it tracks which bet is currently being resolved and stores the winner string returned from the receipt. This lets the UI show a spinner on the correct row and display the winner when it arrives.

The pattern across all hooks is consistent: create a contract instance from the current wallet context, call the contract method, update local state for loading indicators, invalidate relevant caches on success, and show toast notifications for success or failure.


The UI Components

With the hooks in place, the components are straightforward React.

Bets Table

The GenBetsTable component renders every bet as a row in a table with columns for description, status, creator, opponent, winner, and actions:

export function GenBetsTable() {
  const { data: bets, isLoading, isError } = useBets();
  const { address, isConnected } = useWallet();
  const { acceptBet, isAccepting, acceptingBetId } = useAcceptBet();
  const { cancelBet, isCancelling, cancellingBetId } = useCancelBet();
  const { resolveBet, isResolving, resolvingBetId } = useResolveBet();

  // ... loading and error states ...

  return (
    <table>
      {bets.map((bet) => (
        <BetRow
          key={bet.id}
          bet={bet}
          currentAddress={address}
          onAccept={handleAccept}
          onCancel={handleCancel}
          onResolve={handleResolve}
          isAccepting={isAccepting && acceptingBetId === bet.id}
        />
      ))}
    </table>
  );
}
Enter fullscreen mode Exit fullscreen mode

Each row conditionally renders action buttons based on the bet's status and the connected wallet. If you are the invited opponent and the bet is open, you see an "Accept" button. If you are the creator, you see "Cancel." If the bet has been accepted by both parties, anyone can click "Resolve" to trigger AI resolution. Status badges use color coding -- yellow for open, blue for accepted, green for resolved, red for cancelled. Resolved bets display the winner ("creator" or "opponent") in the winner column.

Create Bet Modal

The CreateGenBetModal component is a dialog form with four fields: bet description, resolution URL, resolution prompt, and opponent address. It validates inputs client-side (valid URL format, valid address, cannot bet against yourself) before submitting:

const handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  if (!validateForm()) return;

  createBet({ description, resolutionUrl, resolutionPrompt,
              opponentAddress });
};
Enter fullscreen mode Exit fullscreen mode

The form is straightforward — no stake or amount fields since the contract does not handle token transfers. The modal auto-closes on success and resets its form fields, thanks to a useEffect that watches the mutation's isSuccess state.

Navbar

The Navbar ties everything together with GenBets branding, live statistics (total bets, resolved count), a "Create Bet" button that opens the modal, and the wallet connection panel. The stats update automatically because they derive from the same useBets() query that the table uses -- no extra network calls.


End-to-End Demo

Time to see it all work together.

Step 1: Start the dev server.

npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000 in your browser. You should see the GenBets interface with an empty bets table.

Step 2: Connect your wallet.

Click the wallet button in the navbar. MetaMask will ask you to connect and may prompt you to add the GenLayer Studio network (chain ID 61999). Accept both prompts. Your address appears in the navbar.

If you are using GenLayer Studio, it provides pre-funded test accounts. Import one of those private keys into MetaMask so you have GEN tokens to work with.

Step 3: Create a bet.

Click "Create Bet." Fill in something like:

  • Description: "The Shawshank Redemption has above 90% on Rotten Tomatoes"
  • Resolution URL: https://www.rottentomatoes.com/m/shawshank_redemption
  • Resolution Prompt: "Check the Tomatometer score on this page. If it is above 90%, the creator wins. If it is 90% or below, the opponent wins."
  • Opponent: paste the address of a second test account

Click "Create Bet." MetaMask asks you to sign. Confirm. The button shows a spinner while the transaction moves through consensus. After a few seconds, the bet appears in the table with status "Open."

Step 4: Accept the bet.

Switch MetaMask to the opponent account (the address you entered as the opponent). Refresh the page. You will see the bet with an "Accept" button. Click it, confirm the MetaMask transaction, and the status changes to "Accepted."

Step 5: Resolve with AI.

Click "Resolve." This is where GenLayer does something no other blockchain can do. The transaction triggers your contract's resolve_bet method, which tells validators to fetch the Rotten Tomatoes page, read the score, and determine the winner using your prompt. Multiple validators do this independently and compare their answers through the Equivalence Principle.

Resolution takes longer than a simple transfer -- expect 15 to 30 seconds. The validators are independently browsing the web, running LLM reasoning, and reaching consensus. When they finish, the bet status flips to "Resolved" and the winner ("creator" or "opponent") is displayed.

Step 6: Verify.

Check the winner field on the resolved bet. It will show either "creator" or "opponent" based on what the AI validators determined from the web data.


What You Have Learned

Over three posts, we built a complete dApp on GenLayer from scratch. Here is everything we covered:

Intelligent Contracts. GenLayer's smart contracts are written in Python and can natively access the internet and large language models. No oracles, no off-chain workers -- the contract itself browses the web and reasons about what it finds.

Optimistic Democracy. GenLayer's consensus mechanism where multiple validators independently execute non-deterministic operations (web fetches, LLM calls) and vote on whether their results agree. This is not proof-of-work or proof-of-stake. It is a consensus protocol designed specifically for AI-powered execution.

The Equivalence Principle. The mechanism validators use to compare non-deterministic outputs. Strict equality for exact matches (like our single-word winner output), comparative prompts for "close enough" reasoning, and non-comparative prompts for independent assessment. This is how a blockchain can reach agreement on subjective questions.

GenLayer Studio. The development sandbox where you write, deploy, and test intelligent contracts. It simulates the full validator network locally so you can iterate quickly before deploying to a live network.

genlayer-js. The frontend SDK that connects your JavaScript or TypeScript application to GenLayer. readContract() for views, writeContract() for mutations, waitForTransactionReceipt() to track consensus progress.

The full stack. A Python intelligent contract deployed on GenLayer, connected through genlayer-js to a Next.js frontend, with MetaMask handling wallet management and TanStack Query managing data flow.


Next Steps

GenBets is a working dApp, but it is a starting point, not a finish line.

Deploy to testnet. GenLayer's public testnet, Asimov, lets you test with real (test) validators and real network conditions. Run genlayer network and select testnet, then redeploy your contract.

Extend GenBets. The current design tracks who wins but does not handle token transfers. Consider adding staking functionality where both parties lock tokens and the winner receives the pot. Add bet categories, a reputation system for reliable bettors, or time-limited bets that auto-resolve after a deadline.

Explore the ecosystem. The GenLayer documentation at docs.genlayer.com covers advanced contract patterns, testing strategies, and deployment workflows. Join the GenLayer Discord to connect with other builders. Browse the contract examples on GitHub for inspiration.

You started this series not knowing what an intelligent contract was. Now you have written one, tested it, deployed it, and built a frontend around it. You understand how AI-native consensus works, why the Equivalence Principle matters, and how genlayer-js connects a web app to a blockchain that can think.

Welcome to GenLayer. Now go build something intelligent.

Top comments (0)