Skip to content

Integrate Rewards Display

This tutorial shows you how to build user interfaces that display reward information from both Merkl and Morpho URD. A well-designed rewards display helps users understand their earnings and encourages engagement with your platform.

What to Display

A complete rewards integration should show:

  1. Vault/Market Level Rewards - The APR users can earn
  2. User Claimable Rewards - How much users have earned and can claim
  3. Combined APY/APR - Total yield including base + rewards
  4. Reward Token Information - What tokens are being distributed
  5. Claim Status - What's claimable now vs. pending

Displaying Vault Rewards

Fetching and Calculating Total APY

import { GraphQLClient, gql } from "graphql-request";
 
const client = new GraphQLClient("https://api.morpho.org/graphql");
 
async function getVaultWithRewards(vaultAddress: string, chainId: number) {
  const query = gql`
    query VaultRewards($address: String!, $chainId: Int!) {
      vaults(where: { address: $address, chainId: $chainId }) {
        items {
          address
          name
          symbol
          asset {
            symbol
          }
          state {
            apy
            totalAssets
            rewards {
              yearlySupplyTokens
              supplyApr
              asset {
                address
                symbol
                priceUsd
              }
            }
          }
        }
      }
    }
  `;
 
  const response = await client.request(query, {
    address: vaultAddress,
    chainId,
  });
 
  const vault = response.vaults.items[0];
 
  return {
    vault: vault.name,
    symbol: vault.symbol,
    asset: vault.asset.symbol,
    baseApy: parseFloat(vault.state.apy),
    rewardsApr: vault.state.rewards.reduce(
      (sum, r) => sum + parseFloat(r.supplyApr),
      0
    ),
    totalApy:
      parseFloat(vault.state.apy) +
      vault.state.rewards.reduce((sum, r) => sum + parseFloat(r.supplyApr), 0),
    rewards: vault.state.rewards.map((r) => ({
      token: r.asset.symbol,
      apr: parseFloat(r.supplyApr),
      tokenAddress: r.asset.address,
    })),
  };
}

React Component Example

import React, { useEffect, useState } from "react";
 
interface VaultRewardsDisplayProps {
  vaultAddress: string;
  chainId: number;
}
 
function VaultRewardsDisplay({ vaultAddress, chainId }: VaultRewardsDisplayProps) {
  const [data, setData] = useState<any>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    getVaultWithRewards(vaultAddress, chainId)
      .then(setData)
      .finally(() => setLoading(false));
  }, [vaultAddress, chainId]);
 
  if (loading) return <div>Loading...</div>;
  if (!data) return <div>No data</div>;
 
  return (
    <div className="vault-rewards">
      <h2>{data.vault}</h2>
      <p className="asset">Deposit {data.asset}</p>
 
      {/* Total APY */}
      <div className="apy-display">
        <h3>Total APY: {data.totalApy.toFixed(2)}%</h3>
        <div className="breakdown">
          <div>Base APY: {data.baseApy.toFixed(2)}%</div>
          <div>Rewards APR: +{data.rewardsApr.toFixed(2)}%</div>
        </div>
      </div>
 
      {/* Rewards Breakdown */}
      {data.rewards.length > 0 && (
        <div className="rewards-breakdown">
          <h4>Reward Programs:</h4>
          {data.rewards.map((reward) => (
            <div key={reward.tokenAddress} className="reward-item">
              <span>{reward.token}</span>
              <span>{reward.apr.toFixed(2)}% APR</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Visual Design Tips

Highlight Total Yield:
<div className="total-apy">
  <span className="label">Total APY</span>
  <span className="value">{totalApy.toFixed(2)}%</span>
</div>
Show Breakdown on Hover/Click:
<Tooltip content={
  <div>
    <div>Base APY: {baseApy}%</div>
    <div>MORPHO Rewards: +{morphoApr}%</div>
    <div>Partner Rewards: +{partnerApr}%</div>
  </div>
}>
  <span className="apy">{totalApy}%</span>
</Tooltip>
Use Badges for Rewards:
{rewards.length > 0 && (
  <span className="rewards-badge">
    +{rewardsApr.toFixed(1)}% Rewards
  </span>
)}

Displaying User Claimable Rewards

Fetching User Rewards from Both Systems

async function getUserAllClaimableRewards(userAddress: string, chainId: number) {
  const [merklRewards, urdRewards] = await Promise.all([
    fetchMerklClaimable(userAddress, chainId),
    fetchURDClaimable(userAddress),
  ]);
 
  return {
    merkl: merklRewards,
    urd: urdRewards,
    total: combineRewards(merklRewards, urdRewards),
  };
}
 
async function fetchMerklClaimable(userAddress: string, chainId: number) {
  const response = await fetch(
    `https://api.merkl.xyz/v4/userRewards?user=${userAddress}&chainId=${chainId}`
  );
  const data = await response.json();
 
  const claimable: Array<{ token: string; amount: bigint; symbol: string }> = [];
 
  for (const [campaignAddr, campaignData] of Object.entries(data[chainId] || {})) {
    for (const [tokenAddr, tokenData] of Object.entries(
      campaignData.claimable || {}
    )) {
      claimable.push({
        token: tokenAddr,
        amount: BigInt(tokenData.unclaimed || "0"),
        symbol: tokenData.symbol,
      });
    }
  }
 
  return claimable;
}
 
async function fetchURDClaimable(userAddress: string) {
  const response = await fetch(
    `https://rewards.morpho.org/v1/users/${userAddress}/rewards`
  );
  const rewards = await response.json();
 
  return rewards.map((r) => ({
    token: r.asset.address,
    amount: BigInt(r.amount.claimable_now),
    symbol: r.asset.symbol || "Unknown",
  }));
}
 
function combineRewards(merkl: any[], urd: any[]) {
  const combined: Record<string, { token: string; amount: bigint; symbol: string }> = {};
 
  [...merkl, ...urd].forEach((reward) => {
    if (!combined[reward.token]) {
      combined[reward.token] = { ...reward };
    } else {
      combined[reward.token].amount += reward.amount;
    }
  });
 
  return Object.values(combined);
}

React Component for User Rewards

import { formatUnits } from "viem";
 
function UserRewardsDisplay({ userAddress, chainId }: {
  userAddress: string;
  chainId: number;
}) {
  const [rewards, setRewards] = useState<any>(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    getUserAllClaimableRewards(userAddress, chainId)
      .then(setRewards)
      .finally(() => setLoading(false));
  }, [userAddress, chainId]);
 
  if (loading) return <div>Loading rewards...</div>;
  if (!rewards || rewards.total.length === 0) {
    return <div>No claimable rewards</div>;
  }
 
  return (
    <div className="user-rewards">
      <h3>Your Claimable Rewards</h3>
 
      {rewards.total.map((reward) => (
        <div key={reward.token} className="reward-row">
          <span className="token">{reward.symbol}</span>
          <span className="amount">
            {formatUnits(reward.amount, 18)} {reward.symbol}
          </span>
        </div>
      ))}
 
      {/* Show separate counts for transparency */}
      <div className="source-breakdown">
        <small>
          Merkl: {rewards.merkl.length} rewards • URD: {rewards.urd.length} rewards
        </small>
      </div>
 
      {/* Claim button */}
      <button onClick={() => handleClaimAll(userAddress, chainId)}>
        Claim All Rewards
      </button>
    </div>
  );
}

Displaying Rewards in Tables

For portfolio or dashboard views:

function VaultPortfolio({ userAddress, chainId }: {
  userAddress: string;
  chainId: number;
}) {
  const [vaults, setVaults] = useState<any[]>([]);
 
  return (
    <table>
      <thead>
        <tr>
          <th>Vault</th>
          <th>Your Deposit</th>
          <th>Base APY</th>
          <th>Rewards APR</th>
          <th>Total APY</th>
          <th>Claimable</th>
        </tr>
      </thead>
      <tbody>
        {vaults.map((vault) => (
          <tr key={vault.address}>
            <td>{vault.name}</td>
            <td>
              {vault.userDeposit} {vault.asset.symbol}
            </td>
            <td>{vault.baseApy.toFixed(2)}%</td>
            <td className="rewards-apr">
              +{vault.rewardsApr.toFixed(2)}%
            </td>
            <td className="total-apy">
              <strong>{vault.totalApy.toFixed(2)}%</strong>
            </td>
            <td>
              {vault.claimable > 0 ? (
                <button>Claim</button>
              ) : (
                <span>—</span>
              )}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Showing Reward Program Details

Provide transparency about active programs:

function RewardProgramsCard({ vaultAddress, chainId }) {
  const [programs, setPrograms] = useState<any[]>([]);
 
  // Fetch active programs for this vault
  useEffect(() => {
    fetchActivePrograms(vaultAddress, chainId).then(setPrograms);
  }, [vaultAddress, chainId]);
 
  return (
    <div className="reward-programs">
      <h4>Active Reward Programs</h4>
 
      {programs.map((program) => (
        <div key={program.id} className="program-card">
          <div className="header">
            <img src={program.tokenIcon} alt={program.token} />
            <span>{program.token} Rewards</span>
          </div>
 
          <div className="details">
            <div>APR: {program.apr.toFixed(2)}%</div>
            <div>Ends: {formatDate(program.endDate)}</div>
            <div>Source: {program.isMerkl ? "Merkl" : "Morpho URD"}</div>
          </div>
        </div>
      ))}
    </div>
  );
}

Real-Time Updates

Polling for Updates

function useRewardsPolling(userAddress: string, chainId: number) {
  const [rewards, setRewards] = useState<any>(null);
 
  useEffect(() => {
    const fetchRewards = () => {
      getUserAllClaimableRewards(userAddress, chainId).then(setRewards);
    };
 
    // Initial fetch
    fetchRewards();
 
    // Poll every 5 minutes for Merkl updates
    const interval = setInterval(fetchRewards, 5 * 60 * 1000);
 
    return () => clearInterval(interval);
  }, [userAddress, chainId]);
 
  return rewards;
}

Showing Accruing Rewards

For a more dynamic experience:

function AccruingRewardsDisplay({ baseAmount, ratePerSecond }) {
  const [accrued, setAccrued] = useState(baseAmount);
 
  useEffect(() => {
    const interval = setInterval(() => {
      setAccrued((prev) => prev + ratePerSecond);
    }, 1000);
 
    return () => clearInterval(interval);
  }, [ratePerSecond]);
 
  return (
    <div className="accruing-rewards">
      <span>Accruing: </span>
      <span className="animated-number">
        {accrued.toFixed(6)} MORPHO
      </span>
    </div>
  );
}

Handling Edge Cases

No Rewards Available

{rewards.length === 0 ? (
  <div className="no-rewards">
    <p>No active reward programs for this vault</p>
    <p>Check back later or explore other vaults</p>
  </div>
) : (
  <RewardsList rewards={rewards} />
)}

Failed to Fetch Rewards

{error ? (
  <div className="error-state">
    <p>Unable to load rewards data</p>
    <button onClick={retry}>Retry</button>
  </div>
) : (
  <RewardsDisplay />
)}

Dust Amounts

Don't show tiny amounts that aren't worth claiming:

const DUST_THRESHOLD = 0.01; // $0.01 USD
 
function filterDust(rewards: any[], tokenPrices: Record<string, number>) {
  return rewards.filter((reward) => {
    const usdValue =
      parseFloat(formatUnits(reward.amount, 18)) * tokenPrices[reward.token];
    return usdValue > DUST_THRESHOLD;
  });
}

Accessibility Considerations

  • Use semantic HTML: <table> for tabular data, <button> for actions
  • Provide labels: Screen readers should understand what APY/APR values mean
  • Keyboard navigation: Ensure all interactive elements are keyboard-accessible
  • Loading states: Clear indication when data is loading
<div role="status" aria-live="polite">
  {loading ? "Loading rewards..." : `${rewards.length} rewards available`}
</div>

Resources

Next Steps