Implement SignalR in Next.js for Real-Time Progress Updates
Meta title: Implement SignalR in Next.js with ASP.NET Core | Real-Time Progress Guide
Meta description: Connect Next.js to an ASP.NET Core SignalR hub for live progress updates with authentication, reconnect, and transport fallback.
Keywords: SignalR Next.js, ASP.NET Core SignalR, HubConnectionBuilder, real-time progress
Slug: /blog/implement-signalr-nextjs-real-time-progress
Introduction
When users submit a large volume of data for processing, work rarely ends at “request received.” The backend may still validate records, process data, generate results, and perform follow-up work. Without live feedback, the UI can feel stuck even when the pipeline is healthy.
On an ASP.NET Core backend with a Next.js (App Router) frontend, SignalR is a natural first choice: hub methods, automatic reconnect, and transport fallback so progress reaches a Redux-backed toast without refresh.
This guide covers a SignalR integration for real-time progress updates—an OAuth 2.0 / OpenID Connect identity provider-gated connection, a proxied hub URL scoped by userId, ReceiveMessage, client PING keep-alive, and reconnect timing.
A typical lifecycle needs percent complete, statuses (operationing | importing | completed | failed | cancelled), error counts, and resilience when proxies drop the connection. SignalR provides a persistent hub: the server pushes progress while the client can invoke hub methods for keep-alive or acknowledgments.
Prerequisties
- Familiarity with Next.js App Router and React client components
- An ASP.NET Core backend that exposes a SignalR hub
- An identity provider that issues JWTs; this implementation used an OAuth 2.0 / OpenID Connect identity provider
- A place to store UI progress state; this implementation used Redux
Helpers such as getAccessToken() are project-level utilities, not framework APIs.
Architecture
How it works
- The client connects to the SignalR hub using the hub URL along with the authenticated userId.
- ReceiveMessage: The server pushes progress updates (or PONG responses) to the client in real time.
- GetMessageFromClient: The client sends messages (PING to keep the connection alive or EVENT to notify the server about a specific action).
HUB Url Pattern
{origin}/api/realtime/operation-hub?userId={userIdFromToken}
Where:
- {origin} – The application base URL (e.g., https://app.example.com)
- userId – The authenticated user identifier extracted from the access token
Notes
- Ensure the hub endpoint is reachable through your reverse proxy / gateway with WebSocket support enabled.
- Authenticate every connection and authorize the user to receive updates.
- Client PING interval (e.g., every 30 seconds) helps keep the connection alive.
In this setup, routing through a same-origin Next.js proxy (/api/realtime) helped reduce CORS friction and kept the hub on a path the gateway already routed. This is an implementation choice; the proxy still needs correct WebSocket upgrade support.
Install the client
npm install @microsoft/signalr
The implementation used @microsoft/signalr version 8. In an App Router project, keep SignalR usage in client components ('use client') or browser-only modules because hub connections are not for SSR.
Build the HUB Url from the JWT
The connection is scoped with userId extracted from the an OAuth 2.0 / OpenID Connect identity provider access token.
export const buildSignalRHubUrl = (userId: string): string => {
const baseUrl =
typeof window !== 'undefined' ? window.location.origin : '';
const notificationUrl = baseUrl ? `${baseUrl}/api/realtime` : '';
const hubPath = '/operation-hub';
return `${notificationUrl}${hubPath}?userId=${encodeURIComponent(userId)}`;
};
Connection options used by the implementation include:
- `withCredentials: false`
- WebSockets with Long Polling as a SignalR transport fallback
- `serverTimeoutInMilliseconds`: 120 seconds
- `keepAliveIntervalInMilliseconds`: 15 seconds
Why `skipNegotiation: false` matters
SignalR can skip the negotiate step when a single transport is forced. The implementation kept `skipNegotiation: false` so the client and server can agree on a transport—WebSockets when the upgrade succeeds and Long Polling when it does not.
That is useful behind reverse proxies and API gateways. If you later force WebSockets-only with skipNegotiation: true, the entire network path must support WebSocket upgrades.
.withUrl(hubUrl, {
withCredentials: false,
transport:
signalR.HttpTransportType.WebSockets |
signalR.HttpTransportType.LongPolling,
skipNegotiation: false,
})
Create the SignalR service
A singleton service keeps one hub connection for the authenticated session and fans progress out to subscribers such as a Redux progress state.
import * as signalR from '@microsoft/signalr';
const connection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl, {
withCredentials: false,
transport:
signalR.HttpTransportType.WebSockets |
signalR.HttpTransportType.LongPolling,
skipNegotiation: false,
})
.withAutomaticReconnect([0, 2000, 10_000, 30_000])
.configureLogging(signalR.LogLevel.Information)
.build();
connection.serverTimeoutInMilliseconds = 120_000;
connection.keepAliveIntervalInMilliseconds = 15_000;
connection.on('ReceiveMessage', (data: unknown) => {
if (data === 'PONG') return;
if (data && typeof data === 'object') {
// map jobId / progress / status into Redux
}
});
await connection.start();
How automatic reconnect behaves
.withAutomaticReconnect([0, 2000, 10000, 30000]) retries immediately, then after 2 seconds, 10 seconds, and 30 seconds. Keep these delays compatible with gateway idle timeouts so reconnect attempts do not fight aggressive proxy timeouts.
Understanding `ReceiveMessage`
ReceiveMessage is the server-to-client callback used to push data. In this integration it carried:
1. Progress objects — including jobId, progress percentage, status, totals, completed counts, and error counts.
2. Keep-alive replies — sometimes the literal string "PONG".
Treat progress updates as idempotent. Duplicate messages after reconnect should not corrupt the toast. Map incoming statuses into a stable UI enum such as operationing | importing | completed | failed | cancelled.
Example progress shape:
{
"jobId": "...",
"progress": 42,
"status": "importing",
"totalItems": 100,
"completedItems": 42,
"errorCount": 0
}
Client → server: `GetMessageFromClient`
The client invoked a hub method named GetMessageFromClient with a JSON string.
// Keep-alive
await connection.invoke(
'GetMessageFromClient',
JSON.stringify({ type: 'PING' })
);
// Event message
await connection.invoke(
'GetMessageFromClient',
JSON.stringify({ type: 'EVENT', id: 12345 })
);
An initial PING was sent once the connection reached Connected, followed by a PING every 30 seconds while connected. This is separate from SignalR's own keep-alive interval and matches a backend that expects periodic client messages.
Authentication lifecycle with an OAuth 2.0 / OpenID Connect identity provider
Do not open the hub before identity is ready. The sequence used was:
1. Wait until an OAuth 2.0 / OpenID Connect identity provider is initialized.
2. Confirm the user is authenticated.
3. Read the access token through a project helper such as getAccessToken().
4. Extract userId from the JWT.
5. Build the hub URL and call startConnection.
6. On Connected, send the initial PING and subscribe progress handlers into Redux.
7. On logout or provider unmount, stop the connection and clear intervals.
In practice, this lived behind a React context/provider plus a side-effect-only handler component. The provider owned connection lifecycle while the handler wired progress into the operation UI store.
If an OAuth 2.0 / OpenID Connect identity provider is not ready or userId is missing, skip connecting rather than opening an anonymous hub. This keeps progress scoped to the correct user session.
Production considerations
Ensure the gateway forwards the hub path and allows WebSocket upgrades. An always-on authenticated hub is useful if notifications or other realtime features may share the connection later, but it is heavier when progress is the only consumer.
Log connection state during development, never raw JWTs, push updates into Redux so the UI survives in-app navigation, and use HTTPS/WSS through the proxy in production.
Implementation checklist
- `@microsoft/signalr` installed; hub code runs only in the browser
- Hub URL reachable through the reverse proxy with WebSocket upgrade enabled
- `skipNegotiation: false` with WebSockets | Long Polling
- Connect only after an OAuth 2.0 / OpenID Connect identity provider is initialized and authenticated; `userId` is present
- `ReceiveMessage` mapped; `"PONG"` ignored safely
- Initial and 30-second PING via `GetMessageFromClient`
- Automatic reconnect delays compatible with gateway idle timeouts
- Progress updates are idempotent and statuses are normalized
- Disconnect on logout/unmount
When SignalR is the right choice
SignalR fits best when:
- The backend is already ASP.NET Core with hub infrastructure.
- You want built-in reconnect and Long Polling fallback.
- Multiple realtime features may share one connection, such as progress, notifications, or acknowledgments.
It is less ideal when realtime is only job-scoped operation progress for a single jobId. In that case, an always-on user hub can add protocol and proxy surface you may not need.
Our team later moved to a native WebSocket per operation plus REST polling when disconnected. See /blog/websocket-real-time-progress-nextjs for that current approach.
That migration was a tradeoff decision, not a rejection of SignalR. If your product is becoming a realtime platform, SignalR remains a strong option.
Conclusion
SignalR gives a Next.js client a structured way to receive live progress for long-running operations from ASP.NET Core: negotiate a transport, reconnect automatically, listen on ReceiveMessage, and keep the hub warm with GetMessageFromClient PINGs after an OAuth 2.0 / OpenID Connect identity provider has established who the user is.
Start with SignalR when you need hub semantics and multi-feature room to grow. If the only requirement is progress for one active job ID, evaluate a thinner WebSocket channel.
FAQ
Can I use SignalR with Next.js App Router?
Yes. Use the @microsoft/signalr client from client components or browser-only modules. Connect after authentication so the hub URL can include userId from the JWT.
Is SignalR the same as WebSockets?
No. SignalR is an abstraction over transports. It can use WebSockets and can fall back to Long Polling depending on configuration. Native WebSocket is a direct browser transport without the SignalR hub protocol.