> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fieldfunded.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Sports Betting Website from Scratch

> Full-stack guide to building a sports betting website. Covers architecture, odds display, bet slip, settlement, and deployment. React + Node.js + FieldFunded API.

# How to Build a Sports Betting Website from Scratch

This is a complete guide to building a functional sports betting website — from architecture decisions to deployment. By the end, you will have a working site that displays live odds, accepts bets, and settles them automatically.

**Tech stack**: React frontend, Node.js backend, PostgreSQL database, FieldFunded API for odds and settlement.

## Architecture Overview

```mermaid theme={null}
graph TB
    subgraph Frontend["Browser (React)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"]
        A1["&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Events Browser&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"]
        A2["Bet Slip"]
        A3["Account History"]
    end
    subgraph Backend["Your Backend (Node.js)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"]
        B1["&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Odds Proxy&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"]
        B2["Bet Engine"]
        B3["Settlement Cron"]
    end
    
    A1 --> B1
    A2 --> B2
    A3 --> B3
    
    B1 -->|odds data| C1["FieldFunded API"]
    B2 -->|read/write| D["PostgreSQL"]
    B3 -->|update bets| D
    B3 -->|settlement| C2["FieldFunded API"]
```

Three layers, clean separation:

* **Frontend**: Displays odds, handles bet slip UX, shows account history
* **Backend**: Proxies API calls, validates bets, stores data, runs settlement
* **External**: FieldFunded API for odds data and settlement, PostgreSQL for state

## Step 1: Database Schema

Before writing any code, define your core tables:

```sql theme={null}
-- Users
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  balance DECIMAL(12,2) DEFAULT 0.00,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Bets
CREATE TABLE bets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id),
  event_id VARCHAR(100) NOT NULL,
  event_name VARCHAR(255) NOT NULL,
  market_id VARCHAR(100) NOT NULL,
  market_name VARCHAR(100) NOT NULL,
  selection_id VARCHAR(100) NOT NULL,
  selection_name VARCHAR(100) NOT NULL,
  odds DECIMAL(8,2) NOT NULL,
  stake DECIMAL(12,2) NOT NULL,
  potential_payout DECIMAL(12,2) NOT NULL,
  status VARCHAR(20) DEFAULT 'pending',  -- pending, won, lost, refund
  settled_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Transactions (deposits, withdrawals, bet payouts)
CREATE TABLE transactions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id),
  type VARCHAR(20) NOT NULL,  -- deposit, withdrawal, bet, payout, refund
  amount DECIMAL(12,2) NOT NULL,
  bet_id UUID REFERENCES bets(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Indexes for common queries
CREATE INDEX idx_bets_user ON bets(user_id);
CREATE INDEX idx_bets_status ON bets(status);
CREATE INDEX idx_bets_event ON bets(event_id);
CREATE INDEX idx_transactions_user ON transactions(user_id);
```

## Step 2: Backend — Odds Proxy

Never expose your API key to the frontend. Proxy all calls through your backend:

```javascript theme={null}
// server/routes/odds.js
const express = require("express");
const router = express.Router();

const API_KEY = process.env.FIELDFUNDED_API_KEY;
const BASE = "https://api.fieldfunded.com/v1";

// Cache odds for 5 seconds to reduce API calls
const cache = new Map();
const CACHE_TTL = 5000;

async function cachedFetch(url) {
  const now = Date.now();
  if (cache.has(url) && now - cache.get(url).time < CACHE_TTL) {
    return cache.get(url).data;
  }

  const res = await fetch(url, {
    headers: { "X-API-Key": API_KEY },
  });

  if (!res.ok) throw new Error(`API error: ${res.status}`);
  const data = await res.json();
  cache.set(url, { data, time: now });
  return data;
}

// GET /api/events?sport=soccer
router.get("/events", async (req, res) => {
  try {
    const sport = req.query.sport || "";
    const url = `${BASE}/events${sport ? `?sport=${sport}` : ""}`;
    const data = await cachedFetch(url);
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch events" });
  }
});

// GET /api/events/:id/odds
router.get("/events/:id/odds", async (req, res) => {
  try {
    const data = await cachedFetch(
      `${BASE}/events/${req.params.id}/odds`
    );
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch odds" });
  }
});

// GET /api/live
router.get("/live", async (req, res) => {
  try {
    const data = await cachedFetch(`${BASE}/live`);
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch live events" });
  }
});

module.exports = router;
```

### Why Proxy?

* Your API key stays on the server — never in client JavaScript
* You can add your own caching layer (reduces API usage by 80%+)
* You control rate limiting per user
* You can transform/filter data before sending to the frontend

## Step 3: Backend — Bet Engine

The core business logic — validate bets, deduct balance, store records:

```javascript theme={null}
// server/routes/bets.js
const express = require("express");
const router = express.Router();
const db = require("../db"); // Your PostgreSQL pool
const { requireAuth } = require("../middleware/auth");

// POST /api/bets — Place a bet
router.post("/", requireAuth, async (req, res) => {
  const { eventId, eventName, marketId, marketName,
          selectionId, selectionName, odds, stake } = req.body;

  // Validation
  if (!eventId || !marketId || !selectionId || !odds || !stake) {
    return res.status(400).json({ error: "Missing required fields" });
  }
  if (stake < 1 || stake > 10000) {
    return res.status(400).json({ error: "Stake must be between $1 and $10,000" });
  }

  const client = await db.connect();

  try {
    await client.query("BEGIN");

    // Check balance
    const userRow = await client.query(
      "SELECT balance FROM users WHERE id = $1 FOR UPDATE",
      [req.user.id]
    );
    const balance = parseFloat(userRow.rows[0].balance);

    if (balance < stake) {
      await client.query("ROLLBACK");
      return res.status(400).json({ error: "Insufficient balance" });
    }

    const potentialPayout = Math.round(stake * odds * 100) / 100;

    // Deduct balance
    await client.query(
      "UPDATE users SET balance = balance - $1 WHERE id = $2",
      [stake, req.user.id]
    );

    // Create bet record
    const betResult = await client.query(
      `INSERT INTO bets
       (user_id, event_id, event_name, market_id, market_name,
        selection_id, selection_name, odds, stake, potential_payout)
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
       RETURNING *`,
      [req.user.id, eventId, eventName, marketId, marketName,
       selectionId, selectionName, odds, stake, potentialPayout]
    );

    // Record transaction
    await client.query(
      `INSERT INTO transactions (user_id, type, amount, bet_id)
       VALUES ($1, 'bet', $2, $3)`,
      [req.user.id, -stake, betResult.rows[0].id]
    );

    await client.query("COMMIT");

    res.json({
      bet: betResult.rows[0],
      newBalance: balance - stake,
    });
  } catch (err) {
    await client.query("ROLLBACK");
    res.status(500).json({ error: "Failed to place bet" });
  } finally {
    client.release();
  }
});

// GET /api/bets — User's bet history
router.get("/", requireAuth, async (req, res) => {
  const result = await db.query(
    `SELECT * FROM bets WHERE user_id = $1
     ORDER BY created_at DESC LIMIT 50`,
    [req.user.id]
  );
  res.json({ bets: result.rows });
});

module.exports = router;
```

Key points:

* `FOR UPDATE` locks the user row to prevent race conditions on balance
* Transaction wraps deduction + bet creation + audit log atomically
* Validation happens server-side (never trust the frontend)

## Step 4: Frontend — Event Browser

Display events grouped by sport with real-time odds:

```jsx theme={null}
// src/components/EventList.jsx
import { useState, useEffect } from "react";

export default function EventList({ onSelectEvent }) {
  const [events, setEvents] = useState([]);
  const [sport, setSport] = useState("soccer");
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/events?sport=${sport}`)
      .then((r) => r.json())
      .then((data) => {
        setEvents(data.events || []);
        setLoading(false);
      });
  }, [sport]);

  const sports = [
    { key: "soccer", label: "Soccer" },
    { key: "basketball", label: "Basketball" },
    { key: "tennis", label: "Tennis" },
    { key: "americanfootball", label: "NFL" },
    { key: "esports", label: "Esports" },
  ];

  return (
    <div className="event-list">
      <nav className="sport-tabs">
        {sports.map((s) => (
          <button
            key={s.key}
            className={sport === s.key ? "active" : ""}
            onClick={() => setSport(s.key)}
          >
            {s.label}
          </button>
        ))}
      </nav>

      {loading ? (
        <div className="loading">Loading events...</div>
      ) : (
        <div className="events">
          {events.map((event) => (
            <div
              key={event.id}
              className="event-card"
              onClick={() => onSelectEvent(event)}
            >
              <div className="teams">
                <span>{event.home_team}</span>
                <span className="vs">vs</span>
                <span>{event.away_team}</span>
              </div>
              <div className="meta">
                <span className="league">{event.league}</span>
                <span className="time">
                  {new Date(event.commence_time).toLocaleString()}
                </span>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
```

## Step 5: Frontend — Bet Slip

The bet slip is the checkout experience — keep it simple and clear:

```jsx theme={null}
// src/components/BetSlip.jsx
import { useState } from "react";

export default function BetSlip({ selection, onClear, onBetPlaced }) {
  const [stake, setStake] = useState("");
  const [placing, setPlacing] = useState(false);
  const [error, setError] = useState("");

  if (!selection) return null;

  const stakeNum = parseFloat(stake) || 0;
  const payout = Math.round(stakeNum * selection.odds * 100) / 100;

  async function placeBet() {
    if (stakeNum < 1) {
      setError("Minimum stake is $1");
      return;
    }

    setPlacing(true);
    setError("");

    try {
      const res = await fetch("/api/bets", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          eventId: selection.eventId,
          eventName: selection.eventName,
          marketId: selection.marketId,
          marketName: selection.marketName,
          selectionId: selection.selectionId,
          selectionName: selection.selectionName,
          odds: selection.odds,
          stake: stakeNum,
        }),
      });

      const data = await res.json();

      if (!res.ok) {
        setError(data.error || "Failed to place bet");
        return;
      }

      onBetPlaced(data);
      onClear();
    } catch {
      setError("Network error. Try again.");
    } finally {
      setPlacing(false);
    }
  }

  return (
    <div className="bet-slip">
      <div className="slip-header">
        <h3>Bet Slip</h3>
        <button onClick={onClear}>Clear</button>
      </div>

      <div className="selection-info">
        <p className="event">{selection.eventName}</p>
        <p className="market">{selection.marketName}</p>
        <p className="pick">
          {selection.selectionName}
          <span className="odds">@ {selection.odds}</span>
        </p>
      </div>

      <div className="stake-input">
        <label>Stake ($)</label>
        <input
          type="number"
          min="1"
          max="10000"
          value={stake}
          onChange={(e) => setStake(e.target.value)}
          placeholder="Enter stake"
        />
      </div>

      {stakeNum > 0 && (
        <div className="payout-preview">
          <span>Potential payout</span>
          <span className="amount">${payout.toFixed(2)}</span>
        </div>
      )}

      {error && <p className="error">{error}</p>}

      <button
        className="place-btn"
        onClick={placeBet}
        disabled={placing || stakeNum < 1}
      >
        {placing ? "Placing..." : `Place Bet — $${stakeNum.toFixed(2)}`}
      </button>
    </div>
  );
}
```

## Step 6: Automatic Settlement (Cron Worker)

This is the part most tutorials skip. Run a cron job every 60 seconds to settle finished bets:

```javascript theme={null}
// server/workers/settlement.js
const db = require("../db");

const API_KEY = process.env.FIELDFUNDED_API_KEY;
const BASE = "https://api.fieldfunded.com/v1";

async function settlePendingBets() {
  // Get all pending bets
  const pending = await db.query(
    "SELECT * FROM bets WHERE status = 'pending'"
  );

  if (pending.rows.length === 0) return;

  console.log(`[Settlement] Checking ${pending.rows.length} pending bets`);

  for (const bet of pending.rows) {
    try {
      const res = await fetch(`${BASE}/bets/check`, {
        method: "POST",
        headers: {
          "X-API-Key": API_KEY,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          event_id: bet.event_id,
          market: bet.market_name,
          selection: bet.selection_name,
          stake: parseFloat(bet.stake),
          odds: parseFloat(bet.odds),
          market_id: bet.market_id,
          selection_id: bet.selection_id,
        }),
      });

      const result = await res.json();

      // Skip if event has not finished yet
      if (result.status === "pending") continue;

      // Settle the bet
      const client = await db.connect();
      try {
        await client.query("BEGIN");

        // Update bet status
        await client.query(
          `UPDATE bets SET status = $1, settled_at = NOW()
           WHERE id = $2`,
          [result.status, bet.id]
        );

        // Credit payout if won or refund
        if (result.status === "won" || result.status === "refund") {
          const payout =
            result.status === "won"
              ? parseFloat(bet.potential_payout)
              : parseFloat(bet.stake);

          await client.query(
            "UPDATE users SET balance = balance + $1 WHERE id = $2",
            [payout, bet.user_id]
          );

          await client.query(
            `INSERT INTO transactions (user_id, type, amount, bet_id)
             VALUES ($1, $2, $3, $4)`,
            [bet.user_id, result.status === "won" ? "payout" : "refund",
             payout, bet.id]
          );

          console.log(
            `[Settlement] Bet ${bet.id}: ${result.status} — ` +
            `$${payout} credited to user ${bet.user_id}`
          );
        } else {
          console.log(`[Settlement] Bet ${bet.id}: lost`);
        }

        await client.query("COMMIT");
      } catch (err) {
        await client.query("ROLLBACK");
        console.error(`[Settlement] DB error for bet ${bet.id}:`, err);
      } finally {
        client.release();
      }
    } catch (err) {
      // API error — skip this bet, retry next cycle
      console.error(`[Settlement] API error for bet ${bet.id}:`, err.message);
    }

    // Brief pause between API calls
    await new Promise((r) => setTimeout(r, 200));
  }
}

// Run every 60 seconds
setInterval(settlePendingBets, 60_000);
console.log("[Settlement] Worker started — checking every 60s");

// Initial run
settlePendingBets();
```

This worker:

* Polls all pending bets from your database
* Sends each to the [settlement endpoint](/api-reference/settlements/get-settlements) for resolution
* Credits the user balance on win or refund (transactionally)
* Logs every settlement for auditing
* Handles errors gracefully — failed checks retry on the next cycle

For a deeper dive on the settlement logic and edge cases (partial settlement, dead heats, voided markets), see the [Build a Settlement Engine](/guides/build-settlement-engine) guide.

## Step 7: Player Props Display

Player props are the fastest-growing market category — users expect to bet on individual player performance (points, rebounds, assists, goals, cards), not just match outcomes. The odds response already includes player prop markets alongside standard markets:

```javascript theme={null}
// server/routes/props.js
router.get("/events/:id/props", async (req, res) => {
  try {
    const data = await cachedFetch(
      `${BASE}/events/${req.params.id}/odds`
    );

    // Filter for player prop markets
    const propKeywords = [
      "player", "goalscorer", "points", "rebounds",
      "assists", "threes", "cards", "shots"
    ];

    const props = (data.markets || []).filter((m) => {
      const name = m.name.toLowerCase();
      return propKeywords.some((kw) => name.includes(kw));
    });

    // Group by player name for cleaner display
    const byPlayer = {};
    for (const market of props) {
      for (const sel of market.selections) {
        const player = sel.name.split(" - ")[0] || sel.name;
        if (!byPlayer[player]) byPlayer[player] = [];
        byPlayer[player].push({
          market: market.name,
          line: sel.name,
          odds: sel.odds,
          market_id: market.id,
          selection_id: sel.id,
        });
      }
    }

    res.json({ players: byPlayer, total: props.length });
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch player props" });
  }
});
```

A typical NBA game returns 200+ player prop markets. Soccer matches include goalscorer, cards, and shots props. Player props settle automatically through the same settlement worker — no extra code needed. For a dedicated tracker, see the [Player Props Tracker guide](/guides/build-player-props-tracker).

## Step 8: Live Scores

Show real-time scores alongside your odds. The scores endpoint is lightweight and optimized for frequent polling:

```javascript theme={null}
// server/routes/scores.js
router.get("/scores", async (req, res) => {
  try {
    const sport = req.query.sport || "";
    const data = await cachedFetch(
      `${BASE}/scores${sport ? `?sport=${sport}` : ""}`
    );
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch scores" });
  }
});
```

Scores include period breakdowns — quarters for basketball, halves for soccer and american football, sets for tennis, and map scores for esports (CS2, LoL, Valorant). Poll every 30-60 seconds during live events.

## Step 9: Parlay Support

Parlays (accumulators) are the highest-margin product on any sportsbook. Let users combine multiple selections into a single bet:

```javascript theme={null}
// server/routes/parlays.js
router.post("/parlays", requireAuth, async (req, res) => {
  const { legs, stake } = req.body;

  if (!legs || legs.length < 2 || legs.length > 15) {
    return res.status(400).json({
      error: "Parlays require 2-15 legs",
    });
  }

  // Calculate combined odds
  const combinedOdds = legs.reduce(
    (acc, leg) => acc * leg.odds, 1
  );
  const potentialPayout = Math.round(stake * combinedOdds * 100) / 100;

  const client = await db.connect();
  try {
    await client.query("BEGIN");

    // Check balance
    const userRow = await client.query(
      "SELECT balance FROM users WHERE id = $1 FOR UPDATE",
      [req.user.id]
    );
    if (parseFloat(userRow.rows[0].balance) < stake) {
      await client.query("ROLLBACK");
      return res.status(400).json({ error: "Insufficient balance" });
    }

    // Deduct + store parlay
    await client.query(
      "UPDATE users SET balance = balance - $1 WHERE id = $2",
      [stake, req.user.id]
    );

    const parlayResult = await client.query(
      `INSERT INTO parlays
       (user_id, legs, combined_odds, stake, potential_payout)
       VALUES ($1, $2, $3, $4, $5) RETURNING *`,
      [req.user.id, JSON.stringify(legs), combinedOdds,
       stake, potentialPayout]
    );

    await client.query("COMMIT");
    res.json({ parlay: parlayResult.rows[0] });
  } catch (err) {
    await client.query("ROLLBACK");
    res.status(500).json({ error: "Failed to place parlay" });
  } finally {
    client.release();
  }
});
```

Parlay settlement uses the same [settlement API](/api-reference/settlements/get-settlements) — check each leg individually. If any leg loses, the parlay loses. If a leg is refunded (voided match, postponement), that leg is treated as odds 1.00 and the parlay continues with the remaining legs. See the [Parlay Calculator guide](/guides/build-parlay-calculator) for the full resolution logic.

## Step 10: Search

Let users find specific teams, players, or matchups instantly:

```javascript theme={null}
// server/routes/search.js
router.get("/search", async (req, res) => {
  const q = req.query.q;
  if (!q || q.length < 2) {
    return res.status(400).json({ error: "Query too short" });
  }

  try {
    const data = await cachedFetch(
      `${BASE}/events/search?q=${encodeURIComponent(q)}`
    );
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Search failed" });
  }
});
```

Search works across all sports — soccer, basketball, american football, tennis, esports, and more. Users can search by team name ("Arsenal", "Lakers"), competition ("Champions League", "NFL"), or even specific matchups ("Arsenal vs Chelsea").

## Step 11: Deployment Checklist

| Component       | Recommended hosting           | Cost             |
| --------------- | ----------------------------- | ---------------- |
| React frontend  | Vercel or Cloudflare Pages    | Free             |
| Node.js backend | Railway, Render, or VPS       | \$5-20/mo        |
| PostgreSQL      | Supabase or Neon              | Free tier        |
| FieldFunded API | [Free tier](/compare/pricing) | \$0 (10K req/mo) |
| Domain          | Cloudflare Registrar          | \$10/year        |

**Total cost to launch: under \$25/month.**

## Rate Limit Math

| Component                                                            | Requests/day | Monthly total |
| -------------------------------------------------------------------- | ------------ | ------------- |
| Event browser (soccer, basketball, NFL, tennis, esports — cached 5s) | \~1,700      | 51,000        |
| Odds + player props (50 events/day, cached)                          | \~500        | 15,000        |
| Live scores (30s poll, 10 concurrent events)                         | \~2,880      | 86,400        |
| Settlement worker (100 pending bets, 24x)                            | \~2,400      | 72,000        |
| Search (user-initiated, \~200/day)                                   | \~200        | 6,000         |
| **Total**                                                            | **\~7,680**  | **\~230,400** |

The [Starter plan](/compare/pricing) at \$29/month covers this comfortably. With aggressive caching (5-10s TTL on odds, 30s on scores), you can serve hundreds of concurrent users on a single API tier.

## Security Considerations

Before going live, implement these:

* [ ] Hash passwords with bcrypt (never store plaintext)
* [ ] Rate limit your own API endpoints (express-rate-limit)
* [ ] Validate all inputs server-side (never trust frontend data)
* [ ] Use HTTPS everywhere (Cloudflare provides free SSL)
* [ ] Store API keys in environment variables, never in code
* [ ] Add CSRF protection for bet placement
* [ ] Implement responsible gambling limits (daily/weekly deposit caps)

## Legal Notice

Running a real-money betting platform requires gambling licenses in most jurisdictions. This tutorial covers the technical implementation only. Before accepting real money:

* Consult a lawyer specializing in gambling regulation
* Obtain the required licenses for your target markets
* Implement KYC (Know Your Customer) verification
* Add responsible gambling tools (self-exclusion, deposit limits)

For play-money or educational platforms, no license is typically required.

***

## Related Guides

* [Build a Betting Platform (API Focus)](/guides/build-betting-platform) — deeper dive on the API integration layer
* [Build a Settlement Engine](/guides/build-settlement-engine) — advanced settlement logic and edge cases
* [Build a Real-Time Dashboard](/guides/build-realtime-dashboard) — add WebSocket-based live odds
* [Build a Parlay Calculator](/guides/build-parlay-calculator) — math and logic for multi-leg bets
* [Build a Player Props Tracker](/guides/build-player-props-tracker) — dedicated dashboard for player performance markets
* [Automate Bet Settlement](/blog/automate-bet-settlement) — Node.js cron tutorial for resolving bets
* [Free Sports API Comparison](/compare/free-sports-api) — compare free tier options
* [Affordable Odds API](/compare/affordable-odds-api) — why FieldFunded is the best value for startups
* [FieldFunded vs Sportradar](/compare/vs-sportradar) — full feature comparison

<CardGroup cols={2}>
  <Card title="Get Your Free API Key" icon="key" href="/quickstart">
    Start building today — 10,000 free requests/month
  </Card>

  <Card title="See Pricing" icon="dollar-sign" href="/compare/pricing">
    All plans compared — from free to enterprise
  </Card>
</CardGroup>
