✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem15 min read

How to Build a Live Sports Leaderboard with Strapi 5 and Socket.io

June 11, 2026Updated on June 23, 2026
Build a Live Sports Leaderboard

Polling an API every few seconds to check if a score changed wastes bandwidth and adds latency. A user staring at a leaderboard during a live match doesn't care about data from three seconds ago. For anything time-sensitive (scores, standings, match events), you need the server to push updates the instant data changes.

This tutorial shows how to build a live sports leaderboard with Strapi 5, a headless Content Management System (CMS), and the @strapi-community/plugin-io marketplace plugin, which bundles a production-ready Socket.IO integration. You'll model match data in Strapi's Content-Type Builder, install the plugin, declare which Content-Types should broadcast over WebSockets, and let the plugin handle the rest, no bootstrap glue and no custom Document Service middleware required.

On the frontend, a React app scaffolded with Vite listens for WebSocket events and patches the leaderboard without polling or refreshes. By the end, you'll have a working stack where content changes propagate to every connected browser tab within milliseconds.

In Brief:

  • Model a sports leaderboard in Strapi 5 with Collection Types for teams and matches.
  • Install @strapi-community/plugin-io and configure which Content-Types should auto-broadcast CRUD events.
  • Let the plugin emit real-time updates from any source (REST API, Admin Panel, custom services) without writing bootstrap or middleware code.
  • Connect a React frontend that listens for the plugin's auto-generated WebSocket events and re-renders the leaderboard without polling.

Why Use the IO Plugin Instead of Raw Socket.IO?

You can build this yourself by installing socket.io directly, attaching it to strapi.server.httpServer in bootstrap(), and writing a Document Service middleware that emits events on every match update. That works, but it's roughly 60 lines of plumbing code, requires a few (strapi as any).io casts to work around the typed strapi object, and forces you to handle the Admin-Panel-vs-REST-controller gap by hand.

The @strapi-community/plugin-io plugin (v5.1.0, Strapi 5 compatible, actively maintained through 2026) replaces all of that with a declarative configuration block. You list the Content-Types you want broadcast, the plugin owns the Socket.IO lifecycle, and CRUD events from any source, including Admin Panel saves, are emitted automatically. As a bonus, you get built-in entity-room subscriptions, JWT and API-token authentication, automatic stripping of sensitive fields, rate limiting, and an optional Redis adapter for multi-instance deployments.

Use raw socket.io only if you need protocol-level customization that the plugin doesn't expose (e.g., a server-tick "match clock" event unrelated to any Content-Type write).

Prerequisites and Project Setup

Strapi 5 plus the IO plugin require an active Long-Term Support (LTS) version of Node.js, and Strapi ships with SQLite as its default database, so the setup is minimal.

Before starting, you need:

  • Node.js v20 or v22 LTS (the IO plugin's supported range is Node 18–22; Strapi 5 itself supports up to v24, so stick with 20 or 22 to match both)
  • Basic familiarity with Strapi and React
  • A code editor and terminal

Scaffold the Strapi Project

The create-strapi initializer generates the full project structure, including configuration files, a default SQLite database, and the Admin Panel build. Run it from your terminal:

npx create-strapi@latest sports-leaderboard

The CLI prompts you through setup options. Choose SQLite as the database for simplicity (it's the default), and TypeScript for the language. Once scaffolding completes:

cd sports-leaderboard
npm run develop

A browser tab opens at http://localhost:1337/admin. Register your first admin user there. Confirm the Admin Panel loads before moving on.

Scaffold the React Frontend

Vite scaffolds a minimal React project with hot module replacement out of the box. The only additional dependency is socket.io-client, which handles the WebSocket connection to Strapi. Open a separate terminal:

npm create vite@latest leaderboard-client -- --template react
cd leaderboard-client
npm install
npm install socket.io-client

Use -- to pass arguments like --template through npm to the underlying initializer script. The Vite dev server runs at http://localhost:5173 by default.

You only need a few files in the client: src/socket.js for the Socket.IO singleton, src/hooks/useLeaderboard.js for data fetching and real-time updates, and src/App.jsx for rendering.

Model the Leaderboard Content in Strapi

Before writing any real-time logic, define the data structure. Strapi's Content-Type Builder handles this through the Admin Panel.

Create the Team Collection Type

In the Admin Panel, go to Content-Type Builder → Create new Collection Type. Name it Team, then add these fields:

FieldTypeConfiguration
nameTextRequired
abbreviationTextRequired
logoMediaSingle image
winsIntegerDefault: 0
lossesIntegerDefault: 0
pointsIntegerDefault: 0

Save the content type, then go to Content Manager and seed four to six teams as sample data.

Create the Match Collection Type

The Match Collection Type tracks individual games between two teams, storing each side's score and a status field that controls whether the match appears on the live leaderboard. Add these fields:

FieldTypeConfiguration
homeTeamRelationMany-to-one → Team
awayTeamRelationMany-to-one → Team
homeScoreIntegerDefault: 0
awayScoreIntegerDefault: 0
statusEnumerationValues: scheduled, live, completed
playedAtDatetime

Strapi uses the field names you define in your content model directly in API responses, so homeTeam and homeScore flow through to the frontend exactly as named.

To define relations, select the Relation field type. In the left box, you'll see Match. Click the right box and select Team. Choose the many-to-one icon (many Matches belong to one Team). Name the field homeTeam on the Match side. Repeat for awayTeam.

Add two or three sample matches in the Content Manager. Set at least one to live status for testing later.

Configure API Permissions

Go to Settings → Users & Permissions → Roles → Public. Enable find and findOne for both Team and Match.

Verify with a quick request:

curl "http://localhost:1337/api/teams?populate=*"

Strapi 5 uses a flat response format: fields sit directly on the data object, not nested under data.attributes. It also uses documentId instead of numeric id for all REST API operations. Both of these are breaking changes from v4 that affect how you parse responses throughout the project.

Install and Configure @strapi-community/plugin-io

The IO plugin gives Strapi a managed Socket.IO server, a declarative way to broadcast Content-Type CRUD events, and helpers for rooms, entity subscriptions, and authentication. Install it from the Strapi project root:

npm install @strapi-community/plugin-io

Configure Which Content-Types Broadcast

Create or open config/plugins.ts and declare the plugin. The contentTypes array is the heart of the configuration: each entry maps a Strapi Content-Type UID to the CRUD actions you want broadcast and to a populate rule that controls how relations are included in the emitted payload.

// config/plugins.ts
export default ({ env }) => ({
  io: {
    enabled: true,
    config: {
      contentTypes: [
        {
          uid: 'api::match.match',
          actions: ['create', 'update', 'delete'],
          populate: ['homeTeam', 'awayTeam'],
        },
        {
          uid: 'api::team.team',
          actions: ['update'],
          populate: '*',
        },
      ],
      socket: {
        serverOptions: {
          cors: {
            origin: ['http://localhost:5173'],
            methods: ['GET', 'POST'],
            credentials: true,
          },
        },
      },
    },
  },
});

A few details worth knowing:

  • Event names are auto-generated in the format {contentTypeSlug}:{action}. With the config above, the plugin emits match:create, match:update, match:delete, and team:update automatically whenever those operations succeed.
  • Source-agnostic broadcasting. Because the plugin hooks into the Document Service layer, an update originating from the REST API, the Admin Panel, a custom service, or a plugin all fire the same event. This is the gap that a hand-rolled controller override would miss, since Admin Panel writes bypass custom REST controllers.
  • populate controls payload shape. Setting populate: ['homeTeam', 'awayTeam'] ensures every emitted match:* payload includes the related Team objects, so the frontend doesn't need a follow-up fetch.
  • Sensitive fields are stripped automatically. Common credentials like password, resetPasswordToken, and confirmationToken are removed from payloads before broadcast, so you can safely populate: '*' a user relation without leaking secrets.

Restart Strapi (npm run develop) after editing the config. The plugin's Admin Panel section appears under Settings → IO Plugin once the server boots.

Confirm the Plugin Is Broadcasting

Run a small smoke-test before touching the React app. Save the following as smoke.mjs anywhere on disk and run it with node smoke.mjs (after npm install socket.io-client in that directory):

// smoke.mjs
import { io } from 'socket.io-client';

const socket = io('http://localhost:1337');

socket.on('connect', () => console.log('connected:', socket.id));
socket.on('match:create', (m) => console.log('match:create', m));
socket.on('match:update', (m) => console.log('match:update', m));
socket.on('match:delete', (m) => console.log('match:delete', m));

Leave it running, then in the Admin Panel edit a Match entry and change homeScore. You should see a match:update line printed with the full match payload, including the populated homeTeam and awayTeam. If you do, your real-time pipeline is working end to end before you write any UI code.

A plain wscat connection won't work here: Socket.IO wraps every event in its own Engine.IO framing on top of the raw WebSocket, so you need a Socket.IO-aware client to see parsed events.

Why this is shorter than rolling your own. The previous version of this tutorial required a bootstrap() function that called new Server(strapi.server.httpServer, …), stored the instance on (strapi as any).io, and a register() function with a Document Service middleware that called findOne() to repopulate the entry before emitting. All of that is now handled by the plugin's configuration block above.

Build the React Leaderboard Frontend

The React client fetches the initial leaderboard state from Strapi's REST API on mount, then keeps it current by listening for the plugin's match:update events and merging incoming data into component state.

Connect to Socket.IO and Fetch Initial Data

First, create the Socket.IO singleton. This must live outside the component tree so React re-renders don't recreate the connection:

// src/socket.js
import { io } from 'socket.io-client';

export const socket = io('http://localhost:1337', {
  autoConnect: false,
});

autoConnect: false prevents the socket from connecting until your hook explicitly calls socket.connect(). This gives you control over the connection lifecycle.

Next, create the useLeaderboard hook:

// src/hooks/useLeaderboard.js
import { useEffect, useState, useCallback } from 'react';
import { socket } from '../socket';

const API_URL = 'http://localhost:1337';

export function useLeaderboard() {
  const [matches, setMatches] = useState([]);
  const [isConnected, setIsConnected] = useState(false);

  const fetchMatches = useCallback(async () => {
    const res = await fetch(
      `${API_URL}/api/matches?populate=homeTeam&populate=awayTeam&filters[status][$eq]=live`
    );
    const json = await res.json();
    setMatches(json.data);
  }, []);

  useEffect(() => {
    fetchMatches();

    function onConnect() {
      setIsConnected(true);
    }

    function onDisconnect() {
      setIsConnected(false);
    }

    function onMatchUpdate(payload) {
      // The IO plugin emits the entity object directly, no { data: ... } wrapper.
      setMatches((prev) =>
        prev.map((match) =>
          match.documentId === payload.documentId ? { ...match, ...payload } : match
        )
      );
    }

    function onMatchCreate(payload) {
      // A newly-created match only matters here if it's already live.
      if (payload.status === 'live') {
        setMatches((prev) => [...prev, payload]);
      }
    }

    function onMatchDelete(payload) {
      setMatches((prev) => prev.filter((m) => m.documentId !== payload.documentId));
    }

    socket.on('connect', onConnect);
    socket.on('disconnect', onDisconnect);
    socket.on('match:update', onMatchUpdate);
    socket.on('match:create', onMatchCreate);
    socket.on('match:delete', onMatchDelete);

    socket.connect();

    return () => {
      socket.off('connect', onConnect);
      socket.off('disconnect', onDisconnect);
      socket.off('match:update', onMatchUpdate);
      socket.off('match:create', onMatchCreate);
      socket.off('match:delete', onMatchDelete);
      socket.disconnect();
    };
  }, [fetchMatches]);

  return { matches, isConnected };
}

Two things worth calling out:

  • Named handler references. Functions created during rendering (including anonymous arrow functions) get fresh references on every render. Socket.IO's socket.off(event, listener) removes listeners by matching the exact function reference that was passed to socket.on. Naming the handlers and registering/unregistering the same references avoids a subtle listener-leak.
  • Payload shape. The IO plugin emits the entity object directly as the event data, not wrapped in { data: ... }. Because we configured populate: ['homeTeam', 'awayTeam'] in config/plugins.ts, fields like payload.homeScore and payload.homeTeam.name are available without a follow-up fetch.

Render the Leaderboard Component

The App component consumes the useLeaderboard hook and maps over the matches array to display each live game's teams and score. A connection status indicator shows whether the WebSocket link is active:

// src/App.jsx
import { useLeaderboard } from './hooks/useLeaderboard';

function App() {
  const { matches, isConnected } = useLeaderboard();

  return (
    <div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
      <h1>Live Scoreboard</h1>
      <p>
        Status:{' '}
        <span style={{ color: isConnected ? 'green' : 'red' }}>
          {isConnected ? 'Connected' : 'Disconnected'}
        </span>
      </p>

      {matches.length === 0 && <p>No live matches right now.</p>}

      {matches.map((match) => (
        <div
          key={match.documentId}
          style={{
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            padding: 16,
            marginBottom: 12,
            border: '1px solid #ddd',
            borderRadius: 8,
            position: 'relative',
          }}
        >
          <span style={{ color: 'red', fontWeight: 'bold', fontSize: 12 }}>
            ● LIVE
          </span>
          <div style={{ textAlign: 'center', flex: 1 }}>
            <strong>{match.homeTeam?.name || 'TBD'}</strong>
          </div>
          <div style={{ textAlign: 'center', flex: 1, fontSize: 24 }}>
            {match.homeScore} - {match.awayScore}
          </div>
          <div style={{ textAlign: 'center', flex: 1 }}>
            <strong>{match.awayTeam?.name || 'TBD'}</strong>
          </div>
        </div>
      ))}
    </div>
  );
}

export default App;

Because the initial fetch query filters for status: 'live', all displayed matches show the LIVE indicator. You could extend this by fetching all statuses and rendering different indicators, such as a clock icon for scheduled or a checkmark for completed, based on each match's status field.

Test the Full Flow

The end-to-end test confirms that a content editor saving a score in the Admin Panel triggers a WebSocket event that reaches the React client. Run both projects side by side and follow these steps:

  1. Start Strapi: npm run develop (in the sports-leaderboard directory)
  2. Start the React app: npm run dev (in the leaderboard-client directory)
  3. Open http://localhost:5173 in your browser. You should see the live matches you seeded earlier.
  4. Open the Strapi Admin Panel at http://localhost:1337/admin. Navigate to Content Manager → Match and edit one of the live matches. Change the homeScore from 0 to 1. Hit Save.
  5. Watch the React frontend update without a page refresh.

The score changes appear immediately because the IO plugin catches the Admin Panel save through its Document Service hook, emits a match:update event with the populated payload, and your React hook merges the new data into state.

Go Further: Entity Rooms, Auth, and Reconnection

Production real-time applications need targeted broadcasting, recovery from connection drops, and authenticated WebSocket connections. The IO plugin gives you the first two as primitives and a clean place to plug in the third.

Per-Match Entity Subscriptions

Right now, every match:update event goes to every connected client. When you have dozens of concurrent matches, that's unnecessary traffic. The plugin includes an entity subscription system that auto-creates rooms named {uid}:{id} (for example, api::match.match:42) and lets clients opt in to a single entity's updates.

On the client:

// When a user opens a specific match detail view:
socket.emit('subscribe-entity', {
  uid: 'api::match.match',
  id: matchDocumentId,
});

// When they navigate away:
socket.emit('unsubscribe-entity', {
  uid: 'api::match.match',
  id: matchDocumentId,
});

If you need server-side control (for example, automatically subscribing a freshly-connected user to every live match), use the plugin's helper:

strapi.$io.subscribeToEntity(socket.id, 'api::match.match', matchDocumentId);

Combine entity subscriptions with the plugin's emitToEntity helper when you want a custom server-side event to reach only that match's viewers:

strapi.$io.emitToEntity('api::match.match', matchDocumentId, 'match:commented', {
  commentId,
  author,
});

Reconnection and Missed Updates

Socket.IO reconnection happens automatically, but stale state is a risk. If a client disconnects and misses two score updates, it reconnects with outdated numbers.

Reconnection events fire on the Manager (socket.io), not on the socket instance itself. This is a common pitfall: attaching reconnect to socket.on() silently fails because the event never arrives there.

// In your useLeaderboard hook, add:
function onReconnect() {
  fetchMatches(); // Re-fetch full state from the REST API
}

socket.io.on('reconnect', onReconnect);

// In cleanup:
socket.io.off('reconnect', onReconnect);

Re-fetching from the REST API on reconnect is the simplest way to guarantee consistency.

Authenticate the WebSocket Connection

The IO plugin supports JWT and Strapi API-token authentication during the Socket.IO handshake, so you don't have to write a Socket.IO io.use(…) middleware yourself. The plugin's documented client-side handshake passes the strategy and token through the standard auth object:

// src/socket.js (auth-enabled variant)
import { io } from 'socket.io-client';

export const socket = io('http://localhost:1337', {
  autoConnect: false,
  auth: {
    strategy: 'jwt',
    token: localStorage.getItem('strapiToken'),
  },
});

For a Strapi API token instead of a Users & Permissions JWT, pass strategy: 'apiToken' and the API token value in token. The plugin validates the credential during the handshake and rejects unauthenticated connections before any events flow.

Tip: If you want a fresh token to be sent on every reconnect attempt (useful when the token is short-lived), Socket.IO also accepts a callback form for the auth option: auth: (cb) => cb({ strategy: 'jwt', token: localStorage.getItem('strapiToken') }). Either form works with the plugin.

Server-side, refer to the plugin README for the exact configuration keys that enable or restrict each auth strategy — those keys evolve between minor versions, so it's safer to check the README for your installed version than to copy a config block from a blog post. For background on the JSON Web Token (JWT) approach in Strapi itself, see the Users & Permissions plugin docs.

Where to Take It Next

A few directions to extend this project:

  • Standings table: Recalculate team wins, losses, and points whenever a match's status changes to completed. Add 'update' to the team content type's actions array in config/plugins.ts and the plugin will broadcast team:update automatically.
  • Push notifications: Pair the WebSocket layer with a service worker to notify mobile users about score changes.
  • Webhooks: Trigger external services (analytics, notification platforms) alongside the plugin's broadcasts when scores update.
  • Real-time chat: Add a match-day chat room using the plugin's entity subscription system as the room boundary.
  • Build a developer blog: Apply what you've learned about Strapi 5's content modeling and API to a Next.js frontend project.
  • Multi-instance deployment: If you deploy your app across multiple server instances, enable the plugin's Redis adapter so events propagate across all nodes without you having to wire it manually.

If you plan to deploy this project, Strapi Cloud is one managed option to consider. You can also check the Strapi documentation for production configuration details.

How Strapi Powers This

This tutorial built a CMS-backed leaderboard that pushes live score updates over WebSockets, with no polling and no hand-rolled Socket.IO bootstrap code. Strapi 5 enabled this approach through:

  • Content-Type Builder modeled teams and matches through the Admin Panel without writing schema files.
  • @strapi-community/plugin-io declaratively broadcast every Content-Type CRUD event, including those originating from the Admin Panel, with sensitive fields auto-stripped and relations populated according to a single config block.
  • Entity subscriptions built into the plugin gave a clean primitive for per-match rooms without forcing you to manage Socket.IO rooms yourself.
  • The flat response format let the React frontend read homeScore, awayScore, and status directly off each object with no .attributes unwrapping.
  • REST API filters handled status-based queries so the frontend fetched only live matches.

Ready to build this yourself? Explore the content modeling guide for deeper patterns, then create your first Content-Type today.

Paul BratslavskyDeveloper Advocate

Related Posts

Live Preview
Releases·3 min read

Introducing Live Preview, Client Library and a smoother content editing experience

In-context editing with Live Preview, seamless relation creation, responsive UI updates, and a new Client Library.

·May 13, 2025
Build a Real-time Chat App Using Strapi, Next, Socket.io, and Postgres
App·26 min read

Real-time Chat Application Using Strapi, Next, Socket.io, and PostgreSQL

This blog post has now been updated to Strapi 5.

·June 28, 2022
Build a Real-time Collaborative Text Editor with Vue.js & Strapi
TutorialsIntermediate·15 min read

Build a Real-time Collaborative Text Editor with Vue.js & Strapi

Introduction In today’s fastpaced digital world, a document on which multiple users can collaborate allows for improved...

·August 22, 2024