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-ioand 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-leaderboardThe 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 developA 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-clientUse -- 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:
| Field | Type | Configuration |
|---|---|---|
name | Text | Required |
abbreviation | Text | Required |
logo | Media | Single image |
wins | Integer | Default: 0 |
losses | Integer | Default: 0 |
points | Integer | Default: 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:
| Field | Type | Configuration |
|---|---|---|
homeTeam | Relation | Many-to-one → Team |
awayTeam | Relation | Many-to-one → Team |
homeScore | Integer | Default: 0 |
awayScore | Integer | Default: 0 |
status | Enumeration | Values: scheduled, live, completed |
playedAt | Datetime |
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.
Sign up for the Logbook, Strapi's Monthly newsletter
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-ioConfigure 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 emitsmatch:create,match:update,match:delete, andteam:updateautomatically 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.
populatecontrols payload shape. Settingpopulate: ['homeTeam', 'awayTeam']ensures every emittedmatch:*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, andconfirmationTokenare removed from payloads before broadcast, so you can safelypopulate: '*'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 callednew Server(strapi.server.httpServer, …), stored the instance on(strapi as any).io, and aregister()function with a Document Service middleware that calledfindOne()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 tosocket.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 configuredpopulate: ['homeTeam', 'awayTeam']inconfig/plugins.ts, fields likepayload.homeScoreandpayload.homeTeam.nameare 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:
- Start Strapi:
npm run develop(in thesports-leaderboarddirectory) - Start the React app:
npm run dev(in theleaderboard-clientdirectory) - Open
http://localhost:5173in your browser. You should see the live matches you seeded earlier. - Open the Strapi Admin Panel at
http://localhost:1337/admin. Navigate to Content Manager → Match and edit one of the live matches. Change thehomeScorefrom 0 to 1. Hit Save. - 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
authoption: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, andpointswhenever a match'sstatuschanges tocompleted. Add'update'to theteamcontent type'sactionsarray inconfig/plugins.tsand the plugin will broadcastteam:updateautomatically. - 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-iodeclaratively 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, andstatusdirectly off each object with no.attributesunwrapping. - 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.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.