← Back to App

Building on Celo

A hands-on workshop: build a production MiniPay app from scratch using Claude, Vercel, and the Celo stack.

📋 Copy workshop as Markdown Paste directly into Claude to get a guided build session.
Workshop modules
00 Celopedia — AI-native Celo ecosystem intelligence 01 Setting up Claude with Celo Skills 02 Smart contract security with Pashov 03 GitHub + Vercel for automatic deployments 04 Integrating MiniPay hooks 05 Agents with x402 + MCP + Vercel 06 Building an on-chain dashboard + The Graph 07 Tracking off-chain data with PostHog 08 Scaling a public dashboard without exploding your API
🧠
Module 00
Celopedia — AI-native Celo ecosystem intelligence
verified contracts · live grants · MiniPay checklist · DeFi protocols · one install
🌐 What is Celopedia? An AI skill that gives Claude (and any compatible coding agent) deep, real-time knowledge of the entire Celo ecosystem — verified contract addresses, live grant programs, MiniPay submission requirements, DeFi protocol docs, and security patterns — all queryable in natural language, right inside your editor. No more tab-switching. No more copy-pasting addresses from block explorers. Just ask.
1
Install Celopedia in one command

Run this from your project root. It installs the skill for Claude Code, Cursor, and 12 other AI agents simultaneously.

npx skills add celo-org/celopedia-skills # Installs to Claude Code, Cursor, Cline, Codex, and more # No API key needed — all data is free
💡 Once installed, just ask Claude things like "What is the verified USDT address on Celo?" or "What grants can I apply to right now?" — Celopedia answers instantly with live data.
2
150+ verified contract addresses — never guess again

Wrong contract addresses mean lost funds. Celopedia ships with Celo's canonical verified addresses for every major protocol, updated from official docs. Ask Claude directly instead of searching Celoscan:

# Ask Claude (with Celopedia installed): "What is the Aave V3 Pool address on Celo mainnet?" → 0x3E59A31363E2ad014dcbc521C4a0d5757d9f3402 "What USDT adapter do I use for fee abstraction on Celo?" → 0x0e2a3e05bc9a16f5292a6170456a710cb89c6f72 (adapter, not the token) "What is the USDC address on Celo?" → 0xceba9300f2b948710d2653dd7b07f33a8b32118c
⚠️ Fee abstraction (CIP-64) uses adapter addresses for USDC/USDT — not the token addresses. Using the wrong one silently fails. Celopedia always gives you the right one.
3
Live grant matching — find funding before you build

Celopedia fetches active grant programs from celopg.eco in real time and matches your project to the right ones. Amounts, deadlines, and eligibility criteria — all current, not cached docs from 6 months ago.

# Ask Claude: "I'm building a no-loss savings game on Celo for MiniPay users. What grants can I apply to right now, and what's the pitch?" → Celopedia fetches live programs, matches your profile, and drafts the funding angle for each one.
💡 Celopedia knows about Proof of Ship, Prezenti, Celo Builder Fund, Divvi, GoodBuilders, and 20+ other programs — including which ones are closing soon.
4
MiniPay submission checklist — built in

Getting listed in MiniPay (14M+ wallets) requires passing a strict technical and UX review. Celopedia has the complete official requirements embedded: copy rules, UI constraints, contract verification standards, and country-level targeting notes.

# Ask Claude: "Review my app.js for MiniPay compliance issues." → Celopedia checks for: ✅ Zero-click connect (no wallet button when isMiniPay) ✅ No personal_sign / eth_signTypedData ✅ Banned copy: "gas" → "network fee", "crypto" → "stablecoin" ✅ Only USDT / USDC / USDm — no CELO shown to users ✅ Low-balance redirect to minipay.opera.com/add_cash ✅ SVG/WebP assets only (no PNG) ✅ PageSpeed 90+ on mobile
5
DeFi protocol reference — Aave, Uniswap, Morpho and more

Celopedia includes integration guides for every major DeFi protocol deployed on Celo. Correct ABIs, pool addresses, token decimals, and Celo-specific gotchas — all in one place.

# Protocols covered by Celopedia: - Aave V3 → Pool, aTokens, getReserveData, supplyCap encoding - Uniswap V3 → Swap routing, pool addresses, fee tiers - Morpho Blue → Isolated markets, permissionless creation - Mento → USDm/EURm/BRLm minting, SortedOracles - Superfluid → Streaming payments - x402 → AI agent micropayments (HTTP 402) - stCELO → Liquid staking, exchange rate gotchas
💡 Celopedia also flags Celo-specific security risks: aToken ratio drift, CIP-64 fee abstraction accounting, CELO token duality, and L2 epoch boundary effects — things a generic Solidity auditor will miss.
6
Ecosystem intelligence — 6,300+ products, real-time

Before building a feature, ask Celopedia if it already exists. It queries The Grid — a live index of 6,300+ crypto products — and tells you what's deployed on Celo, what exists on other chains but not on Celo, and where the gaps are.

# Ask Claude: "Is there a no-loss lottery on Celo already?" "What yield products are live in MiniPay today?" "What DeFi protocols exist on other EVM chains but not on Celo?" → Celopedia queries The Grid in real time and gives you a competitive landscape in seconds, not hours.
💡 Zorrito was built using Celopedia — it's how we confirmed there were no direct competitors on Celo and identified which grants to apply to on day one.
Why Celopedia changes how you build on Celo
Never look up a contract address in a browser again
Know which grants exist before you finish your MVP
Catch MiniPay blockers before submitting for review
Avoid Celo-specific bugs that generic tools miss
Validate your idea against 6,300+ live products
Works in Claude Code, Cursor, Cline, Codex, and more
celopedia.celo.org → Install: npx skills add celo-org/celopedia-skills
🤖
Module 01
Setting up Claude with Celo Skills
Claude Code CLI · MCP servers · project context
💡 Claude Code is Anthropic's terminal-based AI agent. With the right MCP servers it can read your contracts, call RPC nodes, deploy to Vercel and push to GitHub — all in one session.
1
Install Claude Code

Install globally via npm, then authenticate with your Anthropic account.

npm install -g @anthropic-ai/claude-code claude # opens interactive session
2
Add project-specific context to CLAUDE.md

Create a CLAUDE.md at the root of your project. Claude reads this on every session — focus it on your contract addresses and conventions. Protocol addresses (USDT, USDC, Aave, etc.) are handled automatically by Celopedia (Module 00) — no need to hardcode them here.

# CLAUDE.md — project-specific context only ## Network - Chain: Celo Mainnet (chainId 42220) - RPC: https://forno.celo.org - Explorer: https://celoscan.io ## My Contracts - MyContract: 0xYOUR_CONTRACT_ADDRESS # Protocol addresses (USDT, USDC, Aave, etc.) → ask Celopedia ## Stack - Frontend: vanilla JS, deployed on Vercel (static) - Backend: Node.js serverless functions (api/*.js) - Wallet: MiniPay first, then WalletConnect fallback
💡 With Celopedia installed (Module 00), you can ask "What's the Aave V3 Pool address on Celo?" at any time — verified, correct, no copy-paste from block explorers.
3
Connect the Celo MCP server

MCP (Model Context Protocol) servers extend Claude with new tools. The Celo MCP gives Claude direct RPC access — it can read balances, simulate txs, and decode events without leaving the conversation.

# Add to ~/.claude/mcp_servers.json { "mcpServers": { "celo": { "command": "npx", "args": ["-y", "@celo/mcp-server"], "env": { "CELO_RPC": "https://forno.celo.org" } } } }
⚠️ Never put private keys in mcp_servers.json. Use env vars loaded from your shell profile instead.
4
Set your starting prompt

Paste this at the start of every Claude session to give it full project context:

You are helping me build a MiniPay mini-app on Celo. Stack: Solidity (contract already deployed), vanilla JS frontend, Node.js Vercel serverless functions, ethers.js v6. RPC: https://forno.celo.org | ChainId: 42220 Always use ethers.js v6 syntax (no .connect() on provider directly, use new ethers.JsonRpcProvider()). Ask before adding dependencies.
🔐
Module 02
Smart contract security with Pashov
Automated audit · Slither · Claude-assisted review
💡 Never deploy without an automated audit pass. Pashov Audit Group publishes open-source security tools — and Claude can run them and explain every finding.
🧠 Two audit layers: Slither + Pashov cover generic Solidity risks (reentrancy, overflow, access control). Celopedia (Module 00) adds the Celo-specific layer on top — aToken ratio drift, CIP-64 fee abstraction accounting, CELO token duality, and L2 epoch effects. Run both.
1
Install Slither (static analyser)

Slither is the industry-standard Solidity static analyser used by Pashov and major audit firms.

pip3 install slither-analyzer # Then ask Claude: "Run slither on contracts/MyContract.sol and explain every HIGH and MEDIUM finding. Suggest a fix for each one."
2
Run the Pashov audit checklist with Claude

Paste this prompt directly into Claude Code — it will read your contract and walk through the Pashov checklist:

Read contracts/MyContract.sol and audit it against the Pashov security checklist: 1. Reentrancy (CEI pattern, ReentrancyGuard) 2. Integer overflow / underflow (Solidity 0.8+ built-in, SafeMath) 3. Access control (onlyOwner, roles, constructor ownership) 4. Front-running / MEV exposure 5. Oracle manipulation 6. Unchecked external calls 7. Centralization risks 8. Event emissions for all state changes For each item: PASS / FAIL / N/A with explanation.
3
Generate a unit test suite

After the audit, ask Claude to generate Hardhat tests that cover every vulnerability it found:

"Based on the audit findings, write a full Hardhat test suite in TypeScript that covers: the happy path, reentrancy attack attempt, unauthorized access, and edge cases around deposit/withdrawal math. Use Celo fork (chainId 42220) via hardhat.config.ts."
🚀
Module 03
GitHub + Vercel for automatic deployments
CI/CD · env vars · preview deployments
💡 Connect Vercel to GitHub once — every push to main auto-deploys. Every PR gets a preview URL. Claude can push, check build logs, and fix errors in one loop.
1
Project structure for Vercel

Vercel serves frontend/ as static files and api/*.js as serverless functions. No framework needed.

my-app/ ├── api/ │ ├── deposit.js # POST → interact with contract │ └── stats.js # GET → read on-chain data ├── frontend/ │ ├── index.html │ ├── app.js │ └── style.css └── vercel.json
2
vercel.json — routes + crons
{ "builds": [ { "src": "api/*.js", "use": "@vercel/node" }, { "src": "frontend/**", "use": "@vercel/static" } ], "routes": [ { "src": "/api/(.*)", "dest": "api/$1.js" }, { "src": "/(.*)", "dest": "frontend/$1" } ], "crons": [ { "path": "/api/daily-job", "schedule": "0 2 * * *" } ], "functions": { "api/daily-job.js": { "maxDuration": 60 } } }
3
Store secrets in Vercel env vars

Never hardcode private keys. Add them in Vercel dashboard → Settings → Environment Variables, then access in code:

// api/any-function.js const privateKey = process.env.MASTER_PRIVATE_KEY; const rpcUrl = process.env.CELO_RPC || "https://forno.celo.org"; const wallet = new ethers.Wallet(privateKey, new ethers.JsonRpcProvider(rpcUrl));
⚠️ Variables prefixed NEXT_PUBLIC_ or listed in env in vercel.json are exposed to the browser. Keep private keys server-only.
4
Let Claude handle the deploy loop

Once GitHub is connected, tell Claude:

"Deploy to production with: npx vercel deploy --prod --yes If the build fails, read the error and fix it, then redeploy."
📱
Module 04
Integrating MiniPay hooks
wallet detection · gasless UX · cUSD payments
💡 MiniPay injects window.ethereum with extra metadata. Detect it first and show a stripped-down UI — no wallet connect modal, no gas warnings.
1
Detect MiniPay reliably
// Works in MiniPay browser AND regular browsers function isMiniPay() { return !!(window.ethereum?.isMiniPay); } async function connectWallet() { if (isMiniPay()) { // MiniPay auto-provides the account — no modal needed const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); return accounts[0]; } // Fallback: WalletConnect or injected return await connectViaWalletConnect(); }
2
Switch to Celo network automatically
const CELO_CHAIN = { chainId: '0xA4EC', // 42220 chainName: 'Celo', rpcUrls: ['https://forno.celo.org'], nativeCurrency: { name: 'CELO', symbol: 'CELO', decimals: 18 }, blockExplorerUrls: ['https://celoscan.io'], }; async function ensureCeloNetwork() { try { await window.ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: CELO_CHAIN.chainId }], }); } catch (err) { if (err.code === 4902) { // chain not added await window.ethereum.request({ method: 'wallet_addEthereumChain', params: [CELO_CHAIN], }); } } }
3
Use USDT approval + transfer pattern

MiniPay users expect a simple "Approve + Deposit" two-step. Always check allowance first to avoid a redundant approval tx.

const USDT = new ethers.Contract(USDT_ADDRESS, ERC20_ABI, signer); async function deposit(amountUSDT) { const amount = ethers.parseUnits(amountUSDT.toString(), 6); // USDT = 6 decimals const allowance = await USDT.allowance(userAddress, CONTRACT_ADDRESS); if (allowance < amount) { const approveTx = await USDT.approve(CONTRACT_ADDRESS, amount); await approveTx.wait(); } const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, signer); const tx = await contract.deposit(amount); await tx.wait(); }
4
MiniPay-specific UX rules

Essential code-level rules while building:

• Show amounts in USD, not raw wei
• Never show a seed phrase or private key UI
• Use font-size: 16px minimum — MiniPay zooms in otherwise
• Test on a real Android device, not just desktop Chrome
🧠 The full MiniPay submission checklist — copy rules, asset formats, PageSpeed targets, country targeting — is covered by Celopedia in Module 00. Ask Claude to run a compliance review before submitting.
Module 05
Agents with x402 + MCP + Vercel
on-chain automation · MCP server · x402 payments · cron jobs
💡 Build a single autonomous agent like zorrito.app/agent.html — a serverless function that runs daily on Vercel, interacts with your contract, exposes MCP tools, and optionally charges AI callers via x402.
1
Agent wallet — one private key, stored in Vercel env

Your agent is just a wallet — store the private key as a Vercel environment variable and load it at runtime.

// api/agent.js — load agent wallet const { ethers } = require("ethers"); const provider = new ethers.JsonRpcProvider( process.env.CELO_RPC || "https://forno.celo.org" ); const agent = new ethers.Wallet(process.env.AGENT_PRIVATE_KEY, provider); module.exports = async (req, res) => { // Agent logic: deposit, feed, withdraw — whatever your contract needs const contract = new ethers.Contract(CONTRACT_ADDRESS, ABI, agent); const tx = await contract.feed(agent.address); res.json({ ok: true, txHash: tx.hash }); };
2
Schedule with Vercel cron

One cron entry in vercel.json hits your agent endpoint daily — no server, no DevOps, free on Vercel hobby plan.

// vercel.json { "crons": [ { "path": "/api/agent", "schedule": "50 5 * * *" } ], "functions": { "api/agent.js": { "maxDuration": 60 } } }
⚠️ Pick a time before your contract's daily deadline. If your feed window closes at 06:00 UTC, schedule the cron at 05:50 UTC — not midnight.
3
Expose an MCP server so Claude can control the agent

Add a /api/mcp endpoint using the MCP SDK. Claude Desktop, Cursor, and any MCP-compatible client can then call your contract in natural language.

// api/mcp.js const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js"); const server = new McpServer({ name: "my-zorrito-agent", version: "1.0.0" }); server.tool("get_fox_status", "Read fox state for a wallet", { wallet: { type: "string", description: "Wallet address" } }, async ({ wallet }) => { const status = await contract.getFoxStatus(wallet); return { content: [{ type: "text", text: JSON.stringify(status) }] }; }); server.tool("feed_fox", "Feed the fox for today (costs 1 USDT)", {}, async () => { const tx = await contract.feed(agent.address); await tx.wait(); return { content: [{ type: "text", text: `Fed! tx: ${tx.hash}` }] }; }); module.exports = async (req, res) => { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); res.on("close", () => transport.close()); await server.connect(transport); await transport.handleRequest(req, res, req.body); };

Add to Claude Desktop by pointing to your endpoint:

{ "mcpServers": { "my-app": { "url": "https://your-app.vercel.app/api/mcp" } } }
4
Fire-and-forget for cron efficiency

Don't await tx.wait() inside crons unless you need the receipt. Submit the tx and return — Celo's sequencing guarantees ordering.

// ✅ fast — submit and return within the 60s cron window const tx = await contract.feed(agent.address); res.json({ ok: true, txHash: tx.hash, status: "submitted" }); // ❌ slow — awaiting confirmation blocks for ~5s and can timeout await tx.wait();
5
x402 payment gate — charge AI agents per API call

Wrap any endpoint with x402 so other AI agents pay USDC on Celo to call your API. No API keys, no subscriptions — pure on-chain micropayments.

const { withPaymentRequired } = require("x402-next"); // or x402-express module.exports = withPaymentRequired( async (req, res) => { // Only runs after payment is verified on-chain res.json({ ok: true, data: await getPremiumData() }); }, { amount: "0.01", // $0.01 USDC per call currency: "USDC", network: "celo", receiver: process.env.PAYMENT_ADDRESS, } );
💡 x402 turns your Vercel function into a monetized AI service. Claude agents, LangChain bots, and any HTTP client that supports EIP-402 can call it autonomously without human approval.
📊
Module 06
Building an on-chain dashboard
ethers.js · event logs · live stats · The Graph subgraph
💡 Start with direct RPC calls — fast to build and free to run. When you need historical data, time-series charts, or leaderboards at scale, add a subgraph on The Graph. Zorrito uses both: RPC for live state, The Graph for the scalable analytics dashboard.
1
Batch RPC calls with Promise.all

Read multiple contract values in parallel — don't await them sequentially:

const [poolSize, totalUsers, apy, myBalance] = await Promise.all([ contract.getTotalDeposited(), contract.getUniqueDepositors(), aavePool.getReserveData(USDT_ADDRESS), contract.getUserDeposit(userAddress), ]);
2
Read historical events efficiently

Use queryFilter with block ranges. Celo has ~5s block time so 30 days ≈ 518,400 blocks. Stay under 2,000 blocks per query or use a public indexer endpoint.

const filter = contract.filters.Deposited(); // your event const toBlock = await provider.getBlockNumber(); const fromBlock = toBlock - 518400; // ~30 days on Celo const events = await contract.queryFilter(filter, fromBlock, toBlock); const stats = events.reduce((acc, e) => { acc.totalVolume += Number(ethers.formatUnits(e.args.amount, 6)); acc.uniqueUsers.add(e.args.user); return acc; }, { totalVolume: 0, uniqueUsers: new Set() });
3
Cache the result in a serverless function

Add a Cache-Control header so Vercel's CDN caches the response and you don't hit the RPC 100× per page load:

// api/stats.js module.exports = async (req, res) => { res.setHeader("Cache-Control", "s-maxage=60, stale-while-revalidate=120"); res.setHeader("Access-Control-Allow-Origin", "*"); const data = await fetchOnChainData(); return res.json({ ok: true, ...data }); };
4
Minimal dashboard HTML pattern
// frontend/app.js — fetch and render async function loadStats() { const res = await fetch("/api/stats?t=" + Date.now()); const data = await res.json(); document.getElementById("pool").textContent = "$" + Number(data.poolSize).toFixed(2); document.getElementById("users").textContent = data.uniqueUsers.toLocaleString(); } loadStats(); setInterval(loadStats, 30_000); // refresh every 30s
5
Scale with The Graph — subgraph for complex queries

Once your dashboard needs time-series charts, lifetime totals, or leaderboards, deploy a subgraph. The Graph indexes your contract events into a GraphQL API — queries are O(1) regardless of how many transactions exist. free on Studio

💡 Why The Graph? Contract view functions only return current state. The Graph keeps the full history: every deposit ever made, daily volume per day, unique users per week — all pre-aggregated and queryable in milliseconds.
① Define your schema
# schema.graphql — one entity per aggregation you need type GlobalStats @entity { id: ID! # always "global" totalTxCount: BigInt! totalDepositedUSDT: BigDecimal! # lifetime gross, not net totalDepositors: Int! activeDepositors: Int! } type DayStat @entity { id: ID! # "YYYY-MM-DD" date: String! txCount: Int! depositVolume: BigDecimal! uniqueUsers: Int! }
② Map events to entities (AssemblyScript)
// src/mappings.ts export function handleDeposited(event: Deposited): void { let global = getOrCreateGlobal(); let amount = normaliseUsdt(event.params.amount); global.totalDepositedUSDT = global.totalDepositedUSDT.plus(amount); global.totalTxCount = global.totalTxCount + 1; global.save(); let day = getOrCreateDayStat(getDayId(event.block.timestamp)); day.depositVolume = day.depositVolume.plus(amount); day.txCount = day.txCount + 1; day.save(); }
③ Deploy to Graph Studio, query from Vercel
// api/graph-stats.js const ENDPOINT = "https://api.studio.thegraph.com/query/YOUR_ID/your-app/v0.0.1"; module.exports = async (req, res) => { res.setHeader("Cache-Control", "s-maxage=300, stale-while-revalidate=600"); const r = await fetch(ENDPOINT, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: `{ globalStats(id: "global") { totalTxCount totalDepositedUSDT totalDepositors activeDepositors } dayStats(first: 30, orderBy: date, orderDirection: desc) { date txCount depositVolume uniqueUsers } }` }), }); const { data } = await r.json(); res.json({ ok: true, source: "the-graph", ...data }); };
⚠️ AssemblyScript gotchas: Use .toI32() not as i32. Use BigDecimal.fromString("0") not BigDecimal.zero(). Avoid parseInt — it doesn't exist. Renaming an entity changes the collection query name, so redeploy as a new version.
📈
Module 07
Tracking off-chain data with PostHog
pageviews · funnels · wallet analytics · privacy-first
💡 PostHog is open-source, self-hostable, and has a generous free tier. Use it to understand your real users: where they come from, how many connect their wallet, and your deposit conversion rate.
1
Add the PostHog snippet to every HTML page

Use the project API key (starts with phc_) — not the personal API key (phx_). Wrong key = silent failure, all zeros in dashboard.

<script> !function(t,e){/* PostHog snippet — copy from app.posthog.com */} posthog.init('phc_YOUR_PROJECT_KEY', { api_host: 'https://us.i.posthog.com', person_profiles: 'always', // track anonymous + identified autocapture: true, // automatic click/form tracking }); </script>
2
Capture custom wallet events
// After wallet connects: posthog.identify(userAddress); // tie future events to this wallet posthog.capture('wallet_connected', { wallet_type: isMiniPay() ? 'minipay' : 'walletconnect', }); // After deposit: posthog.capture('deposit_completed', { amount_usdt: amount }); // After withdraw: posthog.capture('withdraw_completed', { amount_usdt: amount });
3
Proxy the API server-side (keep keys safe)

Never expose your PostHog personal API key (phx_) to the browser. Create a serverless proxy that runs HogQL queries server-side:

// api/analytics.js module.exports = async (req, res) => { res.setHeader("Cache-Control", "s-maxage=300"); const r = await fetch( `https://us.i.posthog.com/api/projects/${process.env.PH_PROJECT_ID}/query/`, { method: "POST", headers: { "Authorization": `Bearer ${process.env.PH_API_KEY}`, // phx_ key "Content-Type": "application/json", }, body: JSON.stringify({ query: { kind: "HogQLQuery", query: `SELECT countDistinctIf(distinct_id, event = '$pageview') as visitors, countDistinctIf(distinct_id, event = 'wallet_connected') as connected FROM events WHERE timestamp >= now() - interval 30 day` } }) } ); const data = await r.json(); res.json({ ok: true, results: data.results }); };
⚠️ Use countDistinctIf() in HogQL, NOT count(distinct CASE WHEN ...) — the CASE WHEN syntax fails silently in PostHog's ClickHouse backend.
4
Run the PostHog wizard for zero-config setup
npx -y @posthog/wizard@latest # Auto-detects your framework and injects the snippet # Works with Next.js, Vite, vanilla HTML
🛡️
Module 08
Scaling a public dashboard without exploding your API
CDN cache · CORS · anti-abuse · 2-min refresh
💡 The stats dashboard at zorrito.app/stats.html handles 10,000 simultaneous users while sending The Graph just one query every 2 minutes — not 10,000. The trick is layered: CDN cache absorbs the volume, CORS blocks unauthorized callers, and the browser never talks to The Graph directly.
1
Never expose The Graph (or any data source) directly to the browser

The browser calls your Vercel API. Your API calls The Graph. The Graph URL lives in an env var — it never appears in client code.

// ❌ wrong — The Graph URL visible to anyone who opens DevTools fetch("https://api.studio.thegraph.com/query/.../zorrito/v0.0.2", { ... }) // ✅ right — browser calls your own API, The Graph stays private fetch("/api/graph-stats")
⚠️ If you expose the subgraph URL directly, anyone can run unlimited queries against it — bypassing your cache entirely and exhausting your free-tier quota.
2
CDN cache with stale-while-revalidate

Two headers turn Vercel's global CDN into a shared cache. 10,000 users requesting the same endpoint simultaneously receive the cached response — The Graph only gets queried when the cache expires.

// api/graph-stats.js res.setHeader("Cache-Control", "s-maxage=120, stale-while-revalidate=240"); // s-maxage=120 → CDN caches for 2 minutes (shared by all users) // stale-while- → after 2 min, CDN serves the old response instantly // revalidate=240 while fetching the fresh one in the background

The math: 10,000 users × 1 req/2min = The Graph receives 1 query per 2 minutes globally — not 10,000.

3
Restrict CORS to your own domain

Setting Access-Control-Allow-Origin: * lets any website call your API from a browser. Restrict it so only your frontend can make cross-origin requests.

// api/graph-stats.js const ALLOWED_ORIGINS = [ "https://zorrito.app", "https://www.zorrito.app", ]; const origin = req.headers.origin; if (origin && !ALLOWED_ORIGINS.includes(origin)) { return res.status(403).json({ ok: false, error: "Forbidden" }); } if (origin) res.setHeader("Access-Control-Allow-Origin", origin); // Note: same-origin requests don't send an origin header — those always pass
4
Block cache-busting attacks

Vercel's CDN caches by exact URL. A bot adding ?t=1234 generates a unique URL on every request — each one hits The Graph directly, bypassing the cache entirely.

// api/graph-stats.js — redirect any request with query params if (Object.keys(req.query).length > 0) { res.setHeader("Cache-Control", "no-store"); return res.redirect(307, "/api/graph-stats"); }
💡 307 (Temporary Redirect) tells the browser to reuse the method (GET) and follow the redirect to the canonical cacheable URL. The browser caches this redirect too, so repeat offenders get stopped earlier.
5
Frontend polling with live countdown

Poll the API every 2 minutes. The stale-while-revalidate header means the user always gets an instant response — no loading spinner, no perceived latency. A live countdown shows when the data was last refreshed.

// frontend/stats.js const POLL_INTERVAL = 120_000; // 2 minutes let lastUpdated = null; async function loadStats() { const res = await fetch("/api/graph-stats"); const data = await res.json(); lastUpdated = Date.now(); renderDashboard(data); } function startCountdown() { const el = document.getElementById("last-updated"); setInterval(() => { if (!lastUpdated) return; const s = Math.floor((Date.now() - lastUpdated) / 1000); el.textContent = s < 60 ? `Updated ${s}s ago` : `Updated ${Math.floor(s/60)}m ago`; }, 1000); } loadStats(); setInterval(loadStats, POLL_INTERVAL); startCountdown();