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:
@strapi-community/plugin-io and configure which Content-Types should auto-broadcast CRUD events.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).
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:
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:
1npx 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:
1cd sports-leaderboard
2npm run developA browser tab opens at http://localhost:1337/admin. Register your first admin user there. Confirm the Admin Panel loads before moving on.
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:
1npm create vite@latest leaderboard-client -- --template react
2cd leaderboard-client
3npm install
4npm 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.
Before writing any real-time logic, define the data structure. Strapi's Content-Type Builder handles this through the Admin Panel.
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.
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.
Go to Settings → Users & Permissions → Roles → Public. Enable find and findOne for both Team and Match.
Verify with a quick request:
1curl "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.
@strapi-community/plugin-ioThe 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:
1npm install @strapi-community/plugin-ioCreate 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.
1// config/plugins.ts
2export default ({ env }) => ({
3 io: {
4 enabled: true,
5 config: {
6 contentTypes: [
7 {
8 uid: 'api::match.match',
9 actions: ['create', 'update', 'delete'],
10 populate: ['homeTeam', 'awayTeam'],
11 },
12 {
13 uid: 'api::team.team',
14 actions: ['update'],
15 populate: '*',
16 },
17 ],
18 socket: {
19 serverOptions: {
20 cors: {
21 origin: ['http://localhost:5173'],
22 methods: ['GET', 'POST'],
23 credentials: true,
24 },
25 },
26 },
27 },
28 },
29});A few details worth knowing:
{contentTypeSlug}:{action}. With the config above, the plugin emits match:create, match:update, match:delete, and team:update automatically whenever those operations succeed.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.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.
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):
1// smoke.mjs
2import { io } from 'socket.io-client';
3
4const socket = io('http://localhost:1337');
5
6socket.on('connect', () => console.log('connected:', socket.id));
7socket.on('match:create', (m) => console.log('match:create', m));
8socket.on('match:update', (m) => console.log('match:update', m));
9socket.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.
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.
First, create the Socket.IO singleton. This must live outside the component tree so React re-renders don't recreate the connection:
1// src/socket.js
2import { io } from 'socket.io-client';
3
4export const socket = io('http://localhost:1337', {
5 autoConnect: false,
6});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:
1// src/hooks/useLeaderboard.js
2import { useEffect, useState, useCallback } from 'react';
3import { socket } from '../socket';
4
5const API_URL = 'http://localhost:1337';
6
7export function useLeaderboard() {
8 const [matches, setMatches] = useState([]);
9 const [isConnected, setIsConnected] = useState(false);
10
11 const fetchMatches = useCallback(async () => {
12 const res = await fetch(
13 `${API_URL}/api/matches?populate=homeTeam&populate=awayTeam&filters[status][$eq]=live`
14 );
15 const json = await res.json();
16 setMatches(json.data);
17 }, []);
18
19 useEffect(() => {
20 fetchMatches();
21
22 function onConnect() {
23 setIsConnected(true);
24 }
25
26 function onDisconnect() {
27 setIsConnected(false);
28 }
29
30 function onMatchUpdate(payload) {
31 // The IO plugin emits the entity object directly, no { data: ... } wrapper.
32 setMatches((prev) =>
33 prev.map((match) =>
34 match.documentId === payload.documentId ? { ...match, ...payload } : match
35 )
36 );
37 }
38
39 function onMatchCreate(payload) {
40 // A newly-created match only matters here if it's already live.
41 if (payload.status === 'live') {
42 setMatches((prev) => [...prev, payload]);
43 }
44 }
45
46 function onMatchDelete(payload) {
47 setMatches((prev) => prev.filter((m) => m.documentId !== payload.documentId));
48 }
49
50 socket.on('connect', onConnect);
51 socket.on('disconnect', onDisconnect);
52 socket.on('match:update', onMatchUpdate);
53 socket.on('match:create', onMatchCreate);
54 socket.on('match:delete', onMatchDelete);
55
56 socket.connect();
57
58 return () => {
59 socket.off('connect', onConnect);
60 socket.off('disconnect', onDisconnect);
61 socket.off('match:update', onMatchUpdate);
62 socket.off('match:create', onMatchCreate);
63 socket.off('match:delete', onMatchDelete);
64 socket.disconnect();
65 };
66 }, [fetchMatches]);
67
68 return { matches, isConnected };
69}Two things worth calling out:
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.{ 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.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:
1// src/App.jsx
2import { useLeaderboard } from './hooks/useLeaderboard';
3
4function App() {
5 const { matches, isConnected } = useLeaderboard();
6
7 return (
8 <div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
9 <h1>Live Scoreboard</h1>
10 <p>
11 Status:{' '}
12 <span style={{ color: isConnected ? 'green' : 'red' }}>
13 {isConnected ? 'Connected' : 'Disconnected'}
14 </span>
15 </p>
16
17 {matches.length === 0 && <p>No live matches right now.</p>}
18
19 {matches.map((match) => (
20 <div
21 key={match.documentId}
22 style={{
23 display: 'flex',
24 justifyContent: 'space-between',
25 alignItems: 'center',
26 padding: 16,
27 marginBottom: 12,
28 border: '1px solid #ddd',
29 borderRadius: 8,
30 position: 'relative',
31 }}
32 >
33 <span style={{ color: 'red', fontWeight: 'bold', fontSize: 12 }}>
34 ● LIVE
35 </span>
36 <div style={{ textAlign: 'center', flex: 1 }}>
37 <strong>{match.homeTeam?.name || 'TBD'}</strong>
38 </div>
39 <div style={{ textAlign: 'center', flex: 1, fontSize: 24 }}>
40 {match.homeScore} - {match.awayScore}
41 </div>
42 <div style={{ textAlign: 'center', flex: 1 }}>
43 <strong>{match.awayTeam?.name || 'TBD'}</strong>
44 </div>
45 </div>
46 ))}
47 </div>
48 );
49}
50
51export 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.
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:
npm run develop (in the sports-leaderboard directory)npm run dev (in the leaderboard-client directory)http://localhost:5173 in your browser. You should see the live matches you seeded earlier.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.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.
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.
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:
1// When a user opens a specific match detail view:
2socket.emit('subscribe-entity', {
3 uid: 'api::match.match',
4 id: matchDocumentId,
5});
6
7// When they navigate away:
8socket.emit('unsubscribe-entity', {
9 uid: 'api::match.match',
10 id: matchDocumentId,
11});If you need server-side control (for example, automatically subscribing a freshly-connected user to every live match), use the plugin's helper:
1strapi.$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:
1strapi.$io.emitToEntity('api::match.match', matchDocumentId, 'match:commented', {
2 commentId,
3 author,
4});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.
1// In your useLeaderboard hook, add:
2function onReconnect() {
3 fetchMatches(); // Re-fetch full state from the REST API
4}
5
6socket.io.on('reconnect', onReconnect);
7
8// In cleanup:
9socket.io.off('reconnect', onReconnect);Re-fetching from the REST API on reconnect is the simplest way to guarantee consistency.
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:
1// src/socket.js (auth-enabled variant)
2import { io } from 'socket.io-client';
3
4export const socket = io('http://localhost:1337', {
5 autoConnect: false,
6 auth: {
7 strategy: 'jwt',
8 token: localStorage.getItem('strapiToken'),
9 },
10});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.
A few directions to extend this project:
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.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.
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:
@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.homeScore, awayScore, and status directly off each object with no .attributes unwrapping.Ready to build this yourself? Explore the content modeling guide for deeper patterns, then create your first Content-Type today.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.