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.
6) (Optional) Persist Messages with EF Core + SQLite
-
Create
Data/Message.cs
andData/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
→ confirmapp.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:
- ANGULAR and ASP. NET Core REST API - Real World Application
- Creating GraphQL APIs with ASP. Net Core for Beginners
- ASP .Net MVC Quick Start
- Master SignalR: Build Real-Time Web Apps with ASP. NET
- Fullstack Asp. Net Core MVC & C# Bootcamp With Real Projects
- ASP. NET Core MVC - A Step by Step Course
Thanks!