by sklls
Claude skill for building full-stack pari-mutuel sports betting apps with Next.js + Supabase
# Add to your Claude Code skills
git clone https://github.com/sklls/betting-app-skillGuides for using ai agents skills like betting-app-skill.
betting-app-skill is an open-source ai agents skill for AI coding assistants such as Claude Code, Codex CLI, and ChatGPT, built by sklls. Claude skill for building full-stack pari-mutuel sports betting apps with Next.js + Supabase. It has 0 GitHub stars.
betting-app-skill's catalog security scan is still queued. You can run an instant dependency and prompt-injection check now with the "Scan for vulnerabilities" button above.
Clone the repository with "git clone https://github.com/sklls/betting-app-skill" and add it to your Claude Code skills directory (see the Installation section above). betting-app-skill ships a SKILL.md manifest, so compatible agents can discover and load it automatically.
Yes. SkillsLLM lists many other AI Agents skills you can browse and compare side by side. Open the AI Agents category from the badge at the top of this page, or use the Related Skills and comparison links further down to weigh betting-app-skill against similar tools.
No comments yet. Be the first to share your thoughts!
Unlocks once the catalog security scan passes (runs nightly).
The deep catalog scan for this skill is still queued. Run an instant dependency check now instead.
You are building a full-stack sports betting platform. This skill encodes hard-won patterns from shipping a real cricket betting app (BCL Bet) that ran a complete tournament with 15+ players, 200+ bets, and zero data integrity incidents.
Reference implementation: https://github.com/sklls/BPL_BET
Core principle: Keep financial logic in Postgres RPC functions, not in application code. The database is the only place that can enforce atomicity.
CREATE TYPE user_role AS ENUM ('user', 'admin');
CREATE TYPE market_status AS ENUM ('open', 'closed', 'settled');
CREATE TYPE bet_status AS ENUM ('pending', 'won', 'lost', 'void');
CREATE TYPE transaction_type AS ENUM ('bet', 'win', 'topup', 'refund');
-- User wallets and roles
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id),
display_name TEXT,
role user_role DEFAULT 'user',
wallet_balance DECIMAL(12,2) DEFAULT 0 CHECK (wallet_balance >= 0)
);
-- The event being bet on
CREATE TABLE matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_a TEXT NOT NULL, team_b TEXT NOT NULL,
match_date TIMESTAMPTZ, venue TEXT,
status TEXT DEFAULT 'upcoming',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- A betting question ("Who wins?", "Top scorer?")
CREATE TABLE markets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
match_id UUID REFERENCES matches(id) ON DELETE CASCADE,
title TEXT, market_type TEXT NOT NULL,
status market_status DEFAULT 'open',
result TEXT, total_pool DECIMAL(12,2) DEFAULT 0,
house_edge_pct DECIMAL(5,2) DEFAULT 5.0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- The choices within a market ("Team A", "Team B")
CREATE TABLE bet_options (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
market_id UUID REFERENCES markets(id) ON DELETE CASCADE,
label TEXT NOT NULL, total_amount_bet DECIMAL(12,2) DEFAULT 0
);
-- Individual user bets
CREATE TABLE bets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id),
market_id UUID REFERENCES markets(id),
bet_option_id UUID REFERENCES bet_options(id),
amount DECIMAL(12,2) NOT NULL CHECK (amount > 0),
odds_at_placement DECIMAL(10,4),
status bet_status DEFAULT 'pending',
payout DECIMAL(12,2),
placed_at TIMESTAMPTZ DEFAULT NOW(), settled_at TIMESTAMPTZ
);
-- Full audit trail of every wallet movement
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id),
type transaction_type NOT NULL,
amount DECIMAL(12,2) NOT NULL,
description TEXT, reference_id UUID,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE VIEW leaderboard AS
SELECT p.id, p.display_name, p.wallet_balance,
COALESCE(SUM(CASE WHEN t.type = 'win' THEN t.amount ELSE 0 END), 0) AS total_winnings,
COUNT(CASE WHEN b.status = 'won' THEN 1 END) AS bets_won,
COUNT(b.id) AS total_bets
FROM profiles p
LEFT JOIN bets b ON b.user_id = p.id
LEFT JOIN transactions t ON t.user_id = p.id
GROUP BY p.id, p.display_name, p.wallet_balance
ORDER BY total_winnings DESC;
-- Enable RLS on all tables
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE bets ENABLE ROW LEVEL SECURITY;
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
ALTER TABLE markets ENABLE ROW LEVEL SECURITY;
ALTER TABLE bet_options ENABLE ROW LEVEL SECURITY;
ALTER TABLE matches ENABLE ROW LEVEL SECURITY;
-- Users see only their own financial data
CREATE POLICY own_bets ON bets FOR ALL USING (auth.uid() = user_id);
CREATE POLICY own_txns ON transactions FOR ALL USING (auth.uid() = user_id);
-- Everyone can read public data (needed for displaying odds)
CREATE POLICY pub_matches ON matches FOR SELECT USING (true);
CREATE POLICY pub_markets ON markets FOR SELECT USING (true);
CREATE POLICY pub_options ON bet_options FOR SELECT USING (true);
CREATE POLICY pub_profiles ON profiles FOR SELECT USING (true);
The pool is split among winners; house takes a cut. Nobody sets prices — the crowd does.
Formula: odds = (total_pool / amount_on_option) x (1 - house_edge%)
// lib/odds.ts
export function calculateOdds(
options: { id: string; total_amount_bet: number }[],
selectedId: string,
extraAmount: number = 0, // include user's preview stake in pool
houseEdgePct: number = 5
): number {
const totalPool = options.reduce((s, o) => s + o.total_amount_bet, 0) + extraAmount
const selected = options.find(o => o.id === selectedId)
if (!selected) return 1
const amountOnSelected = selected.total_amount_bet + extraAmount
if (amountOnSelected <= 0) return 1
const raw = totalPool / amountOnSelected
return Math.max(1.01, raw * (1 - houseEdgePct / 100))
}
export const calcPayout = (stake: number, odds: number) => Math.round(stake * odds * 100) / 100
export const formatOdds = (odds: number) => `${odds.toFixed(2)}x`
Always pass extraAmount for preview — the player's stake is already in the pool.
Clamp minimum to 1.01x so a bet is never worthless.
Never do wallet deduction in application code — race conditions will allow overdrafts.
CREATE OR REPLACE FUNCTION place_bet(
p_user_id UUID, p_market_id UUID, p_bet_option_id UUID,
p_amount DECIMAL, p_odds DECIMAL
) RETURNS JSON AS $$
DECLARE v_balance DECIMAL; v_bet_id UUID;
BEGIN
-- FOR UPDATE locks the row — prevents two concurrent bets from overdrawing
SELECT wallet_balance INTO v_balance FROM profiles
WHERE id = p_user_id FOR UPDATE;
IF v_balance < p_amount THEN RAISE EXCEPTION 'Insufficient balance'; END IF;
UPDATE profiles SET wallet_balance = wallet_balance - p_amount WHERE id = p_user_id;
INSERT INTO bets (user_id, market_id, bet_option_id, amount, odds_at_placement)
VALUES (p_user_id, p_market_id, p_bet_option_id, p_amount, p_odds)
RETURNING id INTO v_bet_id;
UPDATE bet_options SET total_amount_bet = total_amount_bet + p_amount WHERE id = p_bet_option_id;
UPDATE markets SET total_pool = total_pool + p_amount WHERE id = p_market_id;
INSERT INTO transactions (user_id, type, amount, description, reference_id)
VALUES (p_user_id, 'bet', p_amount, 'Bet placed', v_bet_id);
RETURN json_build_object('bet_id', v_bet_id);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
In the API route, always recalculate odds server-side — never trust client-submitted odds.
CREATE OR REPLACE FUNCTION settle_market(p_market_id UUID, p_winning_option_id UUID)
RETURNS VOID AS $$
DECLARE
bet RECORD; v_payout DECIMAL; v_winning_label TEXT;
v_created_at TIMESTAMPTZ; v_cutoff TIMESTAMPTZ; v_is_early BOOLEAN;
BEGIN
SELECT label INTO v_winning_label FROM bet_options WHERE id = p_winning_option_id;
SELECT created_at INTO v_created_at FROM markets WHERE id = p_market_id;
v_cutoff := v_created_at + INTERVAL '30 minutes';
FOR bet IN
SELECT b.id, b.user_id, b.amount, b.odds_at_placement, b.placed_at FROM bets b
WHERE b.market_id = p_market_id AND b.bet_option_id = p_winning_option_id AND b.status = 'pending'
LOOP
v_is_early := bet.placed_at < v_cutoff;
v_payout := bet.amount * bet.odds_at_placement;
IF v_is_early THEN v_payout := v_payout * 1.10; END IF; -- early bird +10%
UPDATE profiles SET wallet_balance = wallet_balance + v_payout WHERE id = bet.user_id;
UPDATE bets SET status = 'won', payout = v_payout, settled_at = NOW() WHERE id = bet.id;
INSERT INTO transactions (user_id, type, amount, description, reference_id) VALUES (
bet.user_id, 'win', v_payout,
CASE WHEN v_is_early THEN 'Early bird +10%: ' || v_winning_label
ELSE 'Bet won: ' || v_winning_label END,
bet.id
);
END LOOP;
UPDATE bets SET status = 'lost', payout = 0, settled_at = NOW()
WHERE market_id = p_market_id AND bet_option_id != p_winning_option_id AND status = 'pending';
UPDATE markets SET status = 'settled', result = v_winning_label, updated_at = NOW()
WHERE id = p_market_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
// lib/supabase-server.ts
// Uses ANON key — respects RLS. For user-scoped data (own bets, own balance).
export function createServerClient() { /* standard Supabase SSR client */ }
// Bypasses RLS entirely. SERVER-SIDE ONLY. Never expose to client.
export function createAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
)
}
Use admin client for: leaderboard, bettors list, admin dashboard, settlement, wallet top-up. Use server client for: user's own bets/balance, placing bets.
Supabase types FK joins as arrays, but FK joins to unique fields return single objects at runtime. This causes Vercel build failures if not handled.
// Wrong: bet.profiles?.display_name <- TS error (thinks it's an array)
// Correct:
const name = (bet.profiles as unknown as { display_name: string } | null)?.display_name ?? 'Unknown'
// Add to every server component/page showing live financial data
export const dynamic = 'force-dynamic'
For admin financial stats, use direct table queries — RPC results can be cached by Vercel:
const [topups, settledBets, winTxns] = await Promise.all([
admin.from('transactions').select('amount').eq('type', 'topup'),
admin.from('bets').select('amount').in('status', ['won', 'lost']),
admin.from('transactions').select('amount').eq('type', 'win'),
])
const totalCashIn = (topups.data ?? []).reduce((s, r) => s + Number(r.amount), 0)
const totalStaked = (settledBets.data ?? []).reduce((s, r) => s + Number(r.amount), 0)
const totalPaidOut = (winTxns.data ?? []).reduce((s, r) => s + Number(r.amount), 0)
Don't rely on server-rendered props for dynamic lists. Fetch client-side on interaction:
useEffect(() => {
if (tab !== 'match') return
fetch('/api/matches').then(r => r.json()).then(setLiveMatches)
}, [tab])
Per-match leaderboard — use admin client, join through markets filtered by match_id:
const { data } = await admin.from('bets')
.select('user_id, amount, status, payout, profiles(display_name), markets!inner(match_id)')
.eq('markets.match_id', matchId)
// aggregate per user_id -> staked, winnings, net P&L, sort by net desc
// Live odds update as user types stake
useEffect(() => {
const num = parseFloat(amount)
if (!isNaN(num) && num > 0)
setPreviewOdds(calculateOdds(market.bet_options, selectedOption.id, num, market.house_edge_pct))
else setPreviewOdds(null)
}, [amount])
// Early bird window check (30 min from market creation)
const isEarlyBird = marketCreatedAt
? new Date() < new Date(new Date(marketCreatedAt).getTime() + 30 * 60 * 1000)
: false
{isEarlyBird && (
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg px-3 py-2 text-xs text-yellow-400">
Early bird! Bet now for a +10% bonus on your payout.
</div>
)}
Only fetch bettors when a market is expanded — don't load all on page mount:
function toggleExpand(marketId: string) {
setExpandedMarkets(prev => {
const next = new Set(prev)
if (next.has(marketId)) { next.delete(marketId) }
else {
next.add(marketId)
if (!bettors[marketId]) fetchBettors(marketId) // lazy
}
return next
})
}
Always read role from the database — JWT claims are cached and won't reflect immediate changes:
const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single()
if (profile?.role !== 'admin') return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
| Pitfall | Fix |
|---|---|
No force-dynamic |
Vercel serves stale balances/odds — add to every financial page |
| Role from JWT | JWT is cached — always read role from profiles table per request |
| Client-side odds only | Players can manipulate — recalculate server-side in bet API |
| No FOR UPDATE in RPC | Race conditions overdraw wallets |
| Anon client for leaderboard | RLS blocks it — use admin client server-side |
| Supabase join types | Vercel build fails — cast with as unknown as ExpectedType |
| Load all bettors on mount | Slow with many markets — lazy-fetch only when expanded |
NEXT_PUBLIC_SUPABASE_URL= # Safe for client
NEXT_PUBLIC_SUPABASE_ANON_KEY= # Safe for client
SUPABASE_SERVICE_ROLE_KEY= # SERVER ONLY - never expose
CRON_SECRET= # Bearer token for cron job auth
NEXT_PUBLIC_SITE_URL= # App URL
First admin: UPDATE profiles SET role = 'admin' WHERE id = 'your-uuid';
A production-tested Claude skill that teaches Claude how to build full-stack sports betting and prediction market apps — with correct odds math, atomic wallet logic, and zero data integrity bugs.
This is a Claude skill — a knowledge file that makes Claude dramatically better at building betting and prediction market apps. Instead of giving generic boilerplate, Claude uses this skill to apply patterns proven in a real production app that ran a full cricket tournament with 15+ players and 200+ bets with zero data integrity incidents.
Reference implementation: github.com/sklls/BPL_BET
Building a betting app looks simple until you hit these walls:
| Problem | What Goes Wrong Without This Skill |
|---|---|
| Race conditions | Two users bet at the same time, one overdrafts their wallet |
| Wrong odds preview | The odds shown to the user before placing don't match the odds recorded after placing |
| Stale data | Admin sees yesterday's totals; leaderboard doesn't update after new bets |
| RLS bypasses | Leaderboard and admin pages silently return empty data because of Row Level Security |
| TypeScript errors | Supabase join types come back as arrays but are actually objects — causes runtime crashes |
| Settlement bugs | Winners don't get paid, or get paid the wrong amount |
Every one of these is handled in the skill with the exact fix.
Full PostgreSQL schema for profiles, matches, markets, bet_options, bets, and transactions — with correct types, constraints, RLS policies, and foreign keys.
The place_bet Postgres function uses SELECT FOR UPDATE to lock the user's wallet row before deducting — preventing any possibility of double-spend, even under concurrent load.
SELECT wallet_balance INTO v_balance
FROM profiles WHERE id = p_user_id FOR UPDATE; -- locks the row
The live odds formula — including the critical extraAmount trick that makes the preview odds match the final recorded odds:
// Include the player's own stake in the preview pool
// so what they see is what they get
export function calculateOdds(
options: BetOption[],
selectedId: string,
extraAmount: number, // <-- this is the key
houseEdgePct: number
): number { ... }
settle_market pays out winning bets with an optional +10% bonus for bettors who placed their bet within the first 30 minutes of a market opening — encouraging early participation over last-minute odds manipulation.
v_early_bird_cutoff := v_market_created_at + INTERVAL '30 minutes';
IF bet.placed_at < v_early_bird_cutoff THEN
v_payout := v_payout * 1.10; -- early bird bonus
END IF;
// Public pages — respects RLS
createServerComponentClient() / createClientComponentClient()
// Admin, leaderboard, bettors API — bypasses RLS with service role key
createAdminClient() // uses SUPABASE_SERVICE_ROLE_KEY
Every page serving financial or live data needs this to prevent Vercel from caching old results:
export const dynamic = 'force-dynamic'
Supabase types .select('profiles(display_name)') as an array, but the join returns a single object. The safe cast pattern:
const name = (bet.profiles as unknown as { display_name: string } | null)
?.display_name ?? 'Unknown'
Full pattern for a leaderboard with tab switching — server-rendered for speed, with client-side fresh fetch to avoid stale dropdown data when new matches are added.
Direct table queries (not RPC) for the financial overview, so numbers always reflect the current state:
// Don't use RPC — it can be stale
// Query the tables directly
const { data: topups } = await admin.from('transactions')
.select('amount').eq('type', 'topup')
A cheat sheet of every painful mistake, what caused it, and the exact fix — built from real production debugging sessions.
.skill file (recommended)betting-app.skill/skills install betting-app.skill
Copy SKILL.md into your Claude skills directory:
~/.claude/skills/betting-app/SKILL.md # Mac / Linux
%APPDATA%\Claude\skills\betting-app\SKILL.md # Windows
Once installed, Claude will automatically apply these patterns whenever you ask it to:
This skill was extracted from BCL Bet — a real cricket tournament betting platform built from scratch over 2 weeks. Every pattern in the skill was learned the hard way:
FOR UPDATE lock came from catching a wallet race condition in testingextraAmount odds trick came from users seeing different odds before and after confirming a betforce-dynamic fix came from the admin dashboard showing week-old totalsThe skill was validated with 3 eval prompts, each run with and without the skill. Average improvement: +60% on key correctness criteria.
| Test | With Skill | Without Skill |
|---|---|---|
| Postgres schema + RPCs | 6/6 ✅ | 2/6 ❌ |
| Pari-mutuel odds engine (TypeScript) | 5/5 ✅ | 4/5 ⚠️ |
| Stale data fixes (Next.js + Supabase) | 4/4 ✅ | 3/4 ⚠️ |
The biggest gaps without the skill: FOR UPDATE row lock, extraAmount in odds preview, force-dynamic on financial pages — subtle but critical.
| Layer | Technology |
|---|---|
| Frontend | Next.js 14 (App Router), TypeScript, Tailwind CSS |
| Backend | Supabase (PostgreSQL + RLS + Realtime) |
| Auth | Supabase Auth |
| Deployment | Vercel |
| Payments | Manual wallet top-up (admin-controlled) |
MIT — use freely, attribution appreciated.
Built from a real production app. Every pattern here was written in anger at 2am after something broke.