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:
- Vault/Market Level Rewards - The APR users can earn
- User Claimable Rewards - How much users have earned and can claim
- Combined APY/APR - Total yield including base + rewards
- Reward Token Information - What tokens are being distributed
- 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>
<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>
{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
- Example App: morpho-org/merkl-morpho-recipe
- Morpho API Playground: api.morpho.org/graphql
- Design Inspiration: app.morpho.org
Next Steps
- Implement claiming: Claim Rewards tutorial
- See complete example: Complete Integration Guide
- Advanced features: Use Bundlers