The whole prediction market, in your pocket.
Live prediction markets, real-time order book depth, and wallet-native trading โ built directly on the Polymarket CLOB API.
Built as a proof-of-concept DevRel integration. No paid API keys. Non-custodial โ your wallet, your keys.
Stack: Next.js 14 (Pages Router) ยท ethers.js v5 ยท Recharts ยท Vercel Serverless
Browser Vercel Serverless (Node.js)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
hooks/useWallet.js pages/api/events.js โ Gamma API
โโ window.ethereum + ethers pages/api/event.js โ Gamma API
pages/api/markets.js โ Gamma API
components/ pages/api/book.js โ CLOB API
MarketCard.jsx pages/api/prices-history.js โ CLOB API
MarketDetail.jsx
Portfolio.jsx
TradeModal.jsx โโdeeplinkโโโ polymarket.com
WalletButton.jsx
lib/clob.js โ SERVER ONLY (Node.js crypto)
All Polymarket API calls go through serverless proxies โ both APIs lack CORS headers for browser origins.
Uses bare window.ethereum + ethers.js v5. No RainbowKit, no wagmi, no WalletConnect needed.
const provider = new ethers.providers.Web3Provider(window.ethereum, 'any');
const signer = provider.getSigner();
// Pass signer directly to ClobClient (server-side) or sign typed data client-sideWorks with: MetaMask, Brave Wallet, Coinbase Wallet, Rabby, any EIP-1193 extension.
Base: https://gamma-api.polymarket.com
| Endpoint | Purpose |
|---|---|
GET /events?limit=N&order=volume24hr&ascending=false |
Paginated events, sorted by 24h volume |
GET /events?tag_slug=politics&limit=N |
Events filtered by tag |
GET /events/{id} |
Single event with all markets |
Base: https://clob.polymarket.com
| Endpoint | Purpose |
|---|---|
GET /book?token_id={TOKEN_ID} |
Order book depth for one outcome token |
GET /prices-history?market={TOKEN_ID}&interval={1h|6h|1d|1w|1m|max}&fidelity=100 |
Price history |
POST /order |
Place signed limit order (requires L2 HMAC auth) |
Base: https://data-api.polymarket.com
| Endpoint | Purpose |
|---|---|
GET /positions?user={PROXY_WALLET}&limit=50 |
User's open positions |
GET /v1/leaderboard?category=OVERALL&limit=20 |
Global leaderboard |
The clob-client uses crypto, stream, http, https, os from Node.js. These don't exist in the browser. Never import lib/clob.js from a component. Import it only from pages/api/* routes.
// โ
Fine โ runs in Node.js
// pages/api/place-order.js
import { placeOrder } from '../../lib/clob';
// โ Crashes browser โ do not do this
// components/TradeModal.jsx
import { placeOrder } from '../lib/clob';Next.js 14 removed automatic Node.js polyfills. Webpack resolve.fallback: { crypto: false } stubs the module but causes runtime TypeErrors when the code actually calls crypto.createHmac().
wagmi@1.xremoveduseSigner(it existed in v0.x only). Any code callinguseSigner()from wagmi 1.x throwsTypeError: useSigner is not a function.- RainbowKit requires a WalletConnect project ID (free at cloud.walletconnect.com) or you get WebSocket 401 noise in the console.
- The wagmi/RainbowKit provider stack adds ~400KB and several peer-dep version constraints.
- Simpler:
window.ethereum+ ethers.js v5 directly. Zero provider wrapping, no hook version drift.
// โ
Correct
GET /prices-history?market=38397507750621893057346880033441136112987238933685677349709401910643842844855
// โ Wrong โ conditionId won't return history
GET /prices-history?market=0x1234...conditionIdToken IDs are the clobTokenIds values from the event's market object (always JSON strings).
const market = event.markets[0];
// โ This crashes
market.clobTokenIds[0]
// โ
Parse first
JSON.parse(market.clobTokenIds)[0] // e.g. "38397507..."
JSON.parse(market.outcomePrices)[0] // e.g. "0.73"
JSON.parse(market.outcomes)[0] // e.g. "Yes"clob.polymarket.com and gamma-api.polymarket.com don't send Access-Control-Allow-Origin for browser requests. All API calls must go through server-side proxies (Vercel API routes in this project).
After code changes, Vercel may serve a cached bundle with the old chunk hash. The symptom: browser throws errors for code you've already fixed. Fix: hard refresh (Cmd+Shift+R) or clear site data. The deployed chunk hash changing in the Network tab confirms the new build is live.
// 4-arg form (v4.x default) โ works for proxy wallets
new ClobClient(host, chainId, signer, creds)
// 6-arg form โ required for EOA (externally owned account) signing
new ClobClient(host, chainId, signer, creds, 0, funderAddress)
// โ SignatureType.EOA = 0
// โ your wallet addressPolymarket uses a two-layer auth model:
- L1 โ EIP-712 signed message proves wallet ownership โ returns L2 API key + secret + passphrase
- L2 โ Every CLOB request is HMAC-signed with the L2 secret
ClobClient.createOrDeriveApiKey() handles L1 and returns L2 creds. Cache them in localStorage โ re-deriving requires a wallet signature every time.
Multi-outcome markets where probabilities sum to 1 (e.g. "Which party wins the Senate?") use a different contract (NegRiskCTFExchange). The enableOrderBook and negRisk flags on the event indicate this. The clob-client handles the routing, but be aware the token structure differs.
const res = await fetch('https://polymarket.com/api/geoblock');
const { restricted } = await res.json(); // true for US IPsShow a warning in the UI before allowing trades from restricted regions.
npm install
npm run dev # http://localhost:3000Environment variables (optional):
# None required for read-only browsing
# For WalletConnect mobile QR: NEXT_PUBLIC_WC_PROJECT_ID=your_id_here
The trade modal collects price/size and opens polymarket.com/event/{slug} for actual execution. This is intentional โ the CLOB signing flow requires server-side @polymarket/clob-client but the EIP-712 signature must come from the user's wallet, which creates a round-trip complexity outside scope for this MVP.
To implement real order placement:
- Server:
GET /api/build-order?tokenID=&price=&size=&side=โ returns EIP-712 typed data - Browser:
signer._signTypedData(domain, types, value)โ signed order - Browser:
POST /api/submit-orderwith signed order + funder address - Server: add L2 HMAC headers, forward to
POST https://clob.polymarket.com/order
The /v1/leaderboard endpoint returns unfiltered data from the Data API. This includes bot accounts and spam usernames with $0 volume that have somehow made it onto the leaderboard (e.g. "2121212121212121212121212" at rank #5 with Vol: $0K).
Root cause: Polymarket's leaderboard API does not enforce minimum volume or username sanity. Entries with volume: 0 (or near-zero) and nonsensical names (all-numeric, repeated characters) appear legitimately in the response.
Observed example:
#5 2121212121212121212121212 Vol: $0K
Recommended client-side fix:
const leaderboard = raw
.filter(entry => parseFloat(entry.volume || 0) >= 100) // drop $0 / dust entries
.filter(entry => !/^[\d\s]{6,}$/.test(entry.name || '')); // drop all-numeric spam namesStatus: Not fixed yet โ currently displayed as-is from the API. Worth filtering before shipping.
The volume field returned by /v1/leaderboard does not correspond to 24h volume or any documented time window. It appears to be a cumulative all-time figure, but Polymarket does not define it in their public docs.
// What the API returns
{ name: "trader123", volume: 48293.12, ... }
// What it is NOT:
// โ 24h volume โ that's event.volume24hr on the Gamma API
// โ 7d volume
// โ any labeled time period
// What it probably is:
// โ
All-time total trading volume for that wallet (undocumented)Implication: Don't label this as "24h Volume" or "Recent Volume" in the UI. "Vol" or "All-time Vol" is the safest label until Polymarket documents the field. The displayed $XXK / $XXM formatting is correct โ the ambiguity is in the time period, not the unit.
The emoji icons next to each category tag (๐ณ๏ธ Politics, ๐ Sports, ๐ฆ Iran, etc.) are defined in a static lookup map in pages/index.jsx and components/MarketCard.jsx:
const tagEmoji = slug => ({
politics:'๐ณ๏ธ', sports:'๐', crypto:'๐ฎ', finance:'๐',
iran:'๐ฆ', geopolitics:'๐', tech:'๐ป', culture:'๐ญ',
economy:'๐ฐ', 'climate-science':'๐ฆ๏ธ', elections:'๐ณ๏ธ',
entertainment:'๐ฌ', nfl:'๐', nba:'๐',
}[slug] || '๐ซง'); // fallbackThe Gamma API returns tag slugs (tag_slug: "politics") but no emoji or icon metadata. Any new tag from the API that isn't in this map falls back to ๐ซง. To support new tags properly, this map needs manual updates.
For prediction markets where candidates haven't been officially announced, Polymarket uses anonymized placeholder names in their API response: Person AN, Person BX, Person CX, etc.
// What the API returns for "Republican Presidential Nominee 2028"
outcomes: '["Will Donald Trump win?","Will Person AN win?","Will Person CX win?"]'These are real Polymarket market entries โ not a bug in the integration. They represent real liquidity on unknown/unannounced candidates.
UI handling: Placeholder outcomes (/^Person [A-Z]+$/) are deprioritised โ sorted to the end of the card so named candidates show first. They still appear in the full market detail view.
Standard browser extensions (MetaMask, Brave Wallet, Rabby) inject window.ethereum into the page. On mobile browsers (Safari, Chrome), no such injection happens โ window.ethereum is undefined, so any call to connect() silently fails or errors with no user feedback.
What we observed:
- "Connect Wallet" button appeared functional but did nothing on mobile Safari
- No error was thrown, no modal appeared โ completely silent failure
- The bug only surfaced when testing on a real device, not in browser DevTools mobile simulation
The fix applied:
// Detect missing wallet before attempting connection
function hasEthereum() {
return typeof window !== 'undefined' && !!window.ethereum;
}
// Show a deep link instead of a broken button
if (!hasEthereum()) {
return (
<a href="https://metamask.app.link/dapp/polypocket.vercel.app">
๐ฆ Get MetaMask
</a>
);
}metamask.app.link/dapp/<url> opens the MetaMask mobile app and navigates to your dapp inside MetaMask's built-in browser, where window.ethereum IS available.
Alternatives for real mobile wallet support:
- WalletConnect v2 โ QR code + deep link flow, works cross-app. Requires a free project ID from cloud.walletconnect.com. Add as
NEXT_PUBLIC_WC_PROJECT_IDenv var. - Coinbase Wallet SDK โ similar mobile deep link approach, no project ID needed
- RainbowKit โ wraps both of the above with a polished modal UI, but adds ~400KB and wagmi dependency complexity (see gotcha #2)