Building a Real-Time Chat App with SignalR and Flutter: What NexTalk Taught Me
NexTalk was a username-based direct-messaging app — the kind of project that looks like a weekend build and turns out to be a very good teacher about auth, connection lifecycles, and deployment.
NexTalk is no longer deployed, but it earned its keep: it's where I worked out how I want to structure a SignalR backend before I use the pattern on anything with paying users. The stack was ASP.NET Core, SignalR for the real-time transport, EF Core with a repository pattern for persistence, JWT for auth, and a Flutter client with a dark-themed UI I mocked up in Figma before writing a line of Dart.
Authenticating a WebSocket connection isn't like authenticating a REST call
The part that trips people up first: SignalR's hub connection is long-lived, so you can't just slap a bearer token on every message the way you would with HTTP requests. The token has to be validated once, at connection time, and then the hub needs a reliable way to know who's talking to it for the lifetime of that connection.
// Startup / Program.cs — accept the JWT from the query string
// for the SignalR handshake, since browsers and some clients
// can't attach custom headers during the WebSocket upgrade.
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments("/hubs/chat"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
On the Flutter side, that meant the SignalR client connects with the token appended to the hub URL rather than in a header — a detail that's easy to miss if you're used to Dio interceptors handling auth for you automatically.
Repository pattern kept the hub itself thin
It's tempting to put EF Core queries directly inside hub methods since the hub is already where the "action" is happening. I didn't, for the same reason I wouldn't put SQL in a Flutter widget: a hub method that's also doing persistence logic is hard to test and harder to change later. Every hub method in NexTalk delegates to a repository interface — IMessageRepository, IConversationRepository — and the hub's only job is translating between "a client called this method" and "broadcast this to the right group."
public class ChatHub : Hub
{
private readonly IMessageRepository _messages;
public async Task SendDirectMessage(string toUsername, string content)
{
var message = await _messages.CreateAsync(
Context.UserIdentifier!, toUsername, content);
await Clients.User(toUsername)
.SendAsync("ReceiveMessage", message);
}
}
Username-based direct messaging (rather than numeric conversation IDs baked into the client) also meant leaning on SignalR's Clients.User(...) targeting, backed by a custom IUserIdProvider that maps a connection to the authenticated username — so a user's messages find them across every device they're logged in on, without the client having to manage connection IDs itself.
The deployment problem that had nothing to do with the code
The most time-consuming bug in this whole project wasn't in SignalR or Flutter at all — it was Railway's build detection silently picking the wrong project in a monorepo that held both the API and unrelated tooling. The build would "succeed" against the wrong .csproj, and the symptoms looked exactly like a runtime configuration issue, which sent me down the wrong debugging path more than once before I traced it back to the build step itself.
What actually fixed it: being explicit rather than trusting auto-detection — pointing Railway directly at the correct project file and root directory instead of letting it infer the entry point from a monorepo with multiple .csproj candidates.
It's a useful reminder that in full-stack work, the backend framework is rarely the hardest part. The hosting platform's assumptions about your repo layout can cost you more debugging time than the actual feature.
Takeaways for anyone pairing SignalR with Flutter
- Pass the JWT through the connection URL for the hub handshake — headers aren't reliable during the WebSocket upgrade on every client.
- Keep hub methods thin. Persistence belongs in a repository layer the hub calls into, not inline EF Core queries.
- If a deployment behaves strangely in a monorepo, check the build detection before you suspect your application code.