Create a Real-Time Chat App with ASP.NET Core 10 and SignalR

by Didin J. on Aug 25, 2025 Create a Real-Time Chat App with ASP.NET Core 10 and SignalR

Build a real‑time chat app with ASP.NET Core 10 and SignalR. Rooms, presence, typing indicators, and optional EF Core + SQLite history.

Build a blazing‑fast real‑time chat app using ASP.NET Core 10 and SignalR with a lightweight frontend (Vanilla JS + Tailwind, no frameworks required). We’ll cover broadcasting, presence (online/offline), rooms (aka groups), and optional message persistence with EF Core + SQLite. By the end, you’ll have a clean, production‑ready starting point you can deploy anywhere.

What You’ll Build

  • A backend in ASP.NET Core 10 exposing a SignalR hub at /hubs/chat.

  • A simple frontend that connects via the @microsoft/signalr JavaScript client.

  • Features:

    • Global broadcast messages

    • Rooms (create/join/leave)

    • Presence (user list, join/leave notifications)

    • Typing indicator

    • (Optional) message history persistence with EF Core + SQLite

Prerequisites

  • .NET SDK 10 (or the latest installed SDK).

  • Node.js 18+ (only if you choose to install the SignalR client via npm; otherwise, use a CDN).

  • A terminal and your favorite editor (VS Code, Rider, or Visual Studio).


1) Create the Solution & Project

In the terminal or CMD:

mkdir aspnetcore10-signalr-chat
cd aspnetcore10-signalr-chat
# Minimal API + static files
dotnet new web -n ChatServer
cd ChatServer

Add useful packages:

# Server side (EF Core only if you want persistence)
dotnet add package Microsoft.AspNetCore.SignalR
# For optional persistence
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design

Project structure we’ll end up with:

ChatServer/
├─ Hubs/
│ └─ ChatHub.cs
├─ Data/ # optional (EF Core)
│ ├─ AppDbContext.cs
│ └─ Message.cs
├─ wwwroot/
│ ├─ index.html
│ ├─ app.js
│ └─ styles.css
├─ Program.cs
└─ appsettings.json


2) Enable Static Files & SignalR in Program.cs

Replace Program.cs with:

using Microsoft.AspNetCore.SignalR;

var builder = WebApplication.CreateBuilder(args);

// Add services
builder.Services.AddSignalR();

// DEV CORS (adjust origins as needed)
builder.Services.AddCors(options =>
{
    options.AddPolicy("dev", policy =>
    policy
    .AllowAnyHeader()
    .AllowAnyMethod()
    .AllowCredentials()
    .SetIsOriginAllowed(_ => true));
});

var app = builder.Build();

app.UseCors("dev");
app.UseDefaultFiles(); // serve index.html by default
app.UseStaticFiles();

app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

app.MapHub<ChatServer.Hubs.ChatHub>("/hubs/chat");

app.Run();

Note: We’ll create Hubs/ChatHub.cs next.


3) Create the Chat Hub

Create Hubs/ChatHub.cs:

using Microsoft.AspNetCore.SignalR;
using System.Collections.Concurrent;


namespace ChatServer.Hubs
{
    public class ChatHub : Hub
    {
        // In‑memory presence
        private static readonly ConcurrentDictionary<string, string> _users = new();


        public override async Task OnConnectedAsync()
        {
            var user = Context.GetHttpContext()?.Request.Query["user"].ToString();
            if (string.IsNullOrWhiteSpace(user))
            {
                user = $"Guest-{Context.ConnectionId[..5]}";
            }


            _users[Context.ConnectionId] = user;


            await Clients.Caller.SendAsync("Presence", _users.Values.Distinct().Order());
            await Clients.Others.SendAsync("UserJoined", user);


            await base.OnConnectedAsync();
        }


        public override async Task OnDisconnectedAsync(Exception? exception)
        {
            if (_users.TryRemove(Context.ConnectionId, out var user))
            {
                await Clients.All.SendAsync("UserLeft", user);
            }
            await base.OnDisconnectedAsync(exception);
        }


        public Task SendMessage(string message)
        {
            var user = _users.GetValueOrDefault(Context.ConnectionId, "Unknown");
            return Clients.All.SendAsync("Message", new { user, message, at = DateTimeOffset.UtcNow });
        }


        public async Task JoinRoom(string room)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, room);
            var user = _users.GetValueOrDefault(Context.ConnectionId, "Unknown");
            await Clients.Group(room).SendAsync("RoomEvent", new { room, type = "join", user });
        }


        public async Task LeaveRoom(string room)
        {
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, room);
            var user = _users.GetValueOrDefault(Context.ConnectionId, "Unknown");
            await Clients.Group(room).SendAsync("RoomEvent", new { room, type = "leave", user });
        }


        public Task SendToRoom(string room, string message)
        {
            var user = _users.GetValueOrDefault(Context.ConnectionId, "Unknown");
            return Clients.Group(room).SendAsync("RoomMessage", new { room, user, message, at = DateTimeOffset.UtcNow });
        }


        public Task Typing(string? room)
        {
            var user = _users.GetValueOrDefault(Context.ConnectionId, "Unknown");
            if (string.IsNullOrEmpty(room))
                return Clients.Others.SendAsync("Typing", user);
            return Clients.Group(room).SendAsync("Typing", user);
        }
    }
}


4) Frontend (Vanilla JS)

Create wwwroot/index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>ASP.NET Core 10 + SignalR Chat</title>
    <link rel="stylesheet" href="/styles.css" />
    <!-- SignalR client via CDN -->
    <script
      defer
      src="https://unpkg.com/@microsoft/signalr@latest/dist/browser/signalr.min.js"
    ></script>
    <script defer src="/app.js"></script>
  </head>
  <body>
    <div class="container">
      <header>
        <h1>SignalR Chat</h1>
        <div class="status" id="status">disconnected</div>
      </header>

      <section class="controls">
        <input id="username" placeholder="Pick a username" />
        <input id="room" placeholder="Room name (optional)" />
        <button id="connectBtn">Connect</button>
        <button id="joinBtn" disabled>Join Room</button>
        <button id="leaveBtn" disabled>Leave Room</button>
      </section>

      <section class="presence">
        <h3>Online</h3>
        <ul id="users"></ul>
      </section>

      <section class="chat">
        <div id="messages" class="messages"></div>
        <div class="typing" id="typing"></div>
        <div class="composer">
          <input id="message" placeholder="Type a message…" disabled />
          <button id="sendBtn" disabled>Send</button>
        </div>
      </section>
    </div>
  </body>
</html>

Create wwwroot/styles.css (simple, framework‑free):

* {
  box-sizing: border-box;
}
body {
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell,
    "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
  margin: 0;
  background: #0b0f19;
  color: #e6e8ee;
}
.container {
  max-width: 960px;
  margin: 0 auto;
  padding: 1rem;
}
header {
  display: flex;
  align-items: center;
  gap: 1rem;
}
.status {
  margin-left: auto;
  font-size: 0.9rem;
  opacity: 0.8;
}
.controls {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin: 0.75rem 0 1rem;
}
.controls input {
  padding: 0.6rem 0.7rem;
  border-radius: 0.6rem;
  border: 1px solid #27304a;
  background: #0f1526;
  color: #e6e8ee;
}
.controls button {
  padding: 0.6rem 0.9rem;
  border-radius: 0.6rem;
  border: 1px solid #27304a;
  background: #1a2240;
  color: #e6e8ee;
  cursor: pointer;
}
.controls button[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}
.presence {
  margin: 1rem 0;
}
.presence ul {
  list-style: none;
  padding-left: 0;
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
}
.presence li {
  background: #121a33;
  border: 1px solid #27304a;
  padding: 0.2rem 0.6rem;
  border-radius: 0.8rem;
}
.chat {
  border: 1px solid #27304a;
  border-radius: 1rem;
  overflow: hidden;
  background: #0f1526;
}
.messages {
  height: 50vh;
  overflow: auto;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.msg {
  background: #121a33;
  border: 1px solid #27304a;
  padding: 0.5rem 0.7rem;
  border-radius: 0.7rem;
}
.msg small {
  opacity: 0.65;
}
.typing {
  min-height: 1.2rem;
  font-style: italic;
  opacity: 0.8;
  padding: 0 1rem;
}
.composer {
  display: flex;
  gap: 0.5rem;
  padding: 0.6rem;
  border-top: 1px solid #27304a;
  background: #0b0f19;
}
.composer input {
  flex: 1;
  padding: 0.6rem 0.7rem;
  border-radius: 0.6rem;
  border: 1px solid #27304a;
  background: #0f1526;
  color: #e6e8ee;
}
.composer button {
  padding: 0.6rem 0.9rem;
  border-radius: 0.6rem;
  border: 1px solid #27304a;
  background: #1a2240;
  color: #e6e8ee;
}

Create wwwroot/app.js:

let connection;
let currentRoom = null;
let username = "";

const el = id => document.getElementById(id);
const status = el("status");
const users = el("users");
const messages = el("messages");
const typing = el("typing");

function addMsg({ user, message, at }) {
  const div = document.createElement("div");
  div.className = "msg";
  const time = at ? new Date(at).toLocaleTimeString() : "";
  div.innerHTML = `<strong>${user}</strong>: ${message} <br><small>${time}</small>`;
  messages.appendChild(div);
  messages.scrollTop = messages.scrollHeight;
}

function setPresence(list) {
  users.innerHTML = "";
  list.forEach(u => {
    const li = document.createElement("li");
    li.textContent = u;
    users.appendChild(li);
  });
}

el("connectBtn").addEventListener("click", async () => {
  username = el("username").value.trim() || "Guest";
  connection = new signalR.HubConnectionBuilder()
    .withUrl(`/hubs/chat?user=${encodeURIComponent(username)}`)
    .withAutomaticReconnect()
    .build();

  // Handlers
  connection.on("Presence", setPresence);
  connection.on("UserJoined", u =>
    addMsg({ user: "system", message: `${u} joined` })
  );
  connection.on("UserLeft", u =>
    addMsg({ user: "system", message: `${u} left` })
  );
  connection.on("Message", addMsg);
  connection.on("RoomEvent", ({ room, type, user }) => {
    addMsg({ user: "system", message: `${user} ${type}ed room #${room}` });
  });
  connection.on("RoomMessage", addMsg);
  connection.on("Typing", u => {
    typing.textContent = `${u} is typing…`;
    clearTimeout(window.__typingTimer);
    window.__typingTimer = setTimeout(() => (typing.textContent = ""), 900);
  });

  connection.onreconnecting(() => (status.textContent = "reconnecting…"));
  connection.onreconnected(() => (status.textContent = "connected"));
  connection.onclose(() => {
    status.textContent = "disconnected";
    el("sendBtn").disabled = true;
    el("message").disabled = true;
    el("joinBtn").disabled = true;
    el("leaveBtn").disabled = true;
  });

  await connection.start();
  status.textContent = "connected";
  el("sendBtn").disabled = false;
  el("message").disabled = false;
  el("joinBtn").disabled = false;

  addMsg({ user: "system", message: `Welcome, ${username}!` });
});

el("sendBtn").addEventListener("click", async () => {
  const text = el("message").value.trim();
  if (!text) return;
  if (currentRoom) await connection.invoke("SendToRoom", currentRoom, text);
  else await connection.invoke("SendMessage", text);
  el("message").value = "";
});

el("message").addEventListener("input", async () => {
  if (!connection) return;
  await connection.invoke("Typing", currentRoom);
});

el("joinBtn").addEventListener("click", async () => {
  const room = el("room").value.trim();
  if (!room) return alert("Enter a room name");
  await connection.invoke("JoinRoom", room);
  currentRoom = room;
  el("leaveBtn").disabled = false;
});

el("leaveBtn").addEventListener("click", async () => {
  if (!currentRoom) return;
  await connection.invoke("LeaveRoom", currentRoom);
  currentRoom = null;
  el("leaveBtn").disabled = true;
});


5) Run the App

dotnet run

Open http://localhost:5278 in two browser tabs, enter different usernames, and test broadcasting and room chats.

Create a Real-Time Chat App with ASP.NET Core 10 and SignalR - chat app


6) (Optional) Persist Messages with EF Core + SQLite

  • Create Data/Message.cs and Data/AppDbContext.cs.

  • Register EF Core in Program.cs.

  • Update ChatHub to save messages.

  • Expose REST /api/messages to fetch history.

  • Update frontend to fetch history on room join.


7) Production Checklist

  • Lock down CORS

  • Add authentication

  • Add rate limiting

  • Use Redis backplane for scaling

  • Enable logging/telemetry

  • Force HTTPS in production


8) Dockerize

FROM mcr.microsoft.com/dotnet/sdk:latest AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /out


FROM mcr.microsoft.com/dotnet/aspnet:latest
WORKDIR /app
COPY --from=build /out .
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "ChatServer.dll"]


9) Troubleshooting

  • CORS errors → check origins

  • WebSocket blocked → proxy headers

  • 404 /hubs/chat → confirm app.MapHub


10) Conclusion

You now have a real‑time chat app with ASP.NET Core 10 + SignalR, including rooms, presence, typing indicators, and optional persistence. Ready to extend with auth, files, or SPA frontend.

You can get the full source code on our GitHub.

That's just the basics. If you need more deep learning about ASP.NET Core, Angular, or related, you can take the following cheap course:

Thanks!