Svelte doesn’t get anything like the kind of love that React receives, but that doesn’t mean developers should sleep on it. Last week saw the release of Svelte 5, and while this update has React-ified Svelte a little with the introduction of runes such as $state()
and $effect()
, there is still enough elegance and power in this JavaScript framework that its clear why it is the most admired web framework among developers.
Here, we want to show you a little of that elegance and power by putting together a Svelte chat app. We’ll take advantage of Svelte’s component approach and a few runes to create a performant, reactive chat interface demonstrating why Svelte's streamlined approach to state management and component composition makes it an excellent choice for modern web applications.
Understanding Svelte's Component Approach
Before we get into the core of our build, let’s take a step back and look at the Svelte framework and its component-based architecture.
Unlike React, which relies heavily on the virtual DOM and runtime overhead, Svelte takes a radical approach by shifting most of the heavy lifting to compile time. This means when you write a Svelte component, it's compiled into highly optimized vanilla JavaScript that directly manipulates the DOM. No virtual DOM diffing, no runtime framework overhead–just pure, efficient JavaScript that runs exactly when and where it's needed.
What makes Svelte particularly elegant is its declarative syntax, which makes it feel more like writing enhanced HTML than JavaScript. Each Svelte component is a .svelte
file that can contain three sections: script (for JavaScript logic), template (for HTML markup), and style (for component-scoped CSS). Here's a quick example:
<script>
let count = 0;
const increment = () => count += 1;
</script>
<button on:click={increment}>
Clicked {count} times
</button>
<style>
button {
background: #ff3e00;
color: white;
}
</style>
This simplicity is deceptive–underneath, Svelte is doing some pretty sophisticated work. When you modify the count variable, Svelte automatically updates the DOM without any need for setState()
calls or hooks. This reactivity is baked into the framework at the compiler level, making it more performant and intuitive to work with.
One of the newer features is Svelte 5's runes. While they might look familiar to React developers, they serve a different purpose. Instead of managing component lifecycle, runes in Svelte provide a more explicit way to declare reactive states and effects. For example:
<script>
let messages = $state([]);
$effect(() => {
if (messages.length > 100) {
messages = messages.slice(-100);
}
});
</script>
This code would maintain message history and automatically trims it when it gets too long, all with minimal boilerplate and maximum performance.
Let's put these concepts into practice by building our chat interface.
Setting Up Your Svelte Chat Project
Svelte is Yet Another JavaScript Framework. While that will annoy some, it means that the extensive tooling of the JS ecosystem is available for use with Svelte in the same way it is with React, Angular, or Next.js.
In particular, npm and Vite. We’ll use Vite to set up our project and give us the skeleton code that we can build upon.
npm create vite@latest svelte-chat -- --template svelte
This will set up the project structure for us. We can then enter the directory and install our dependencies:
cd svelte-chat
npm install
We’ll also be using the Stream JavaScript SDK to handle all the complex parts of the chat application, so we need to install that package as well. Then we can start the dev server:
npm install stream-chat
npm run dev
This will create a server on localhost:5173
, and when you open it up, you’ll see a little counter that you can click to increment:
This is controlled by the basic code above. The exact code in the project isn’t critical to us right now, but it’s good to look at a few of the different files we have:
- index.html – This is your entry point HTML file. Unlike React's typically sparse index.html, Svelte's template includes some useful meta tags and serves as the mounting point for your application. The script tag that imports main.js is where Svelte begins its magic.
- src/main.js – This is where your Svelte application bootstraps itself. It creates the root component and mounts it to the DOM. You'll rarely need to modify this file unless you add global styles or configure app-wide plugins.
- src/App.svelte – This is your root component, analogous to App.js in React. It's where you'll define your app's layout and routing structure. It's particularly clean in Svelte because the framework's component syntax allows you to combine JavaScript, HTML, and scoped CSS in one file without the visual clutter.
- src/lib/Counter.svelte – This is an example component that demonstrates Svelte's reactivity system. While we won't use this specific component, it's a good reference for how Svelte handles state changes and events. The location in the 'lib' directory is also a convention worth noting: it's where you'll typically store your reusable components.
Here, we will be building out our Chat component in the src/lib directory and passing that to our App.svelte file for compilation and rendering. These files form the backbone of a Svelte project, providing a clean separation of concerns while maintaining the tight coupling that makes Svelte so efficient at runtime.
Implementing Real-Time Chat
Let’s start by creating our Chat component. We need two basic UI elements for the app: a way for a user to ‘log in’ and the actual chat interface itself.
User and Client Initialization
We’ll start with the login. Here, we just want this to be an input field that allows users to add a user name. In a real chat app, you might be building out authentication yourself or using a service like Clerk, Auth0, or an SSO provider to perform authentication and provide user names.
Here is the code in our Chat component to handle the login and user generation. First, we have the script section handling state and initialization:
//src/lib/Chat.svelte
<script>
import { onDestroy } from 'svelte';
import { StreamChat } from 'stream-chat';
let chatClient;
let userName = ''; // New variable for user name
let userId = ''; // New variable for user ID
const appKey = 'stream-app-key';
function generateUserId(name) {
return name.toLowerCase().replace(/\s+/g, '_') + '_' + Math.floor(Math.random() * 10000);
}
async function getToken() {
const response = await fetch('http://localhost:3000/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId }),
});
const data = await response.json();
return data.token;
}
async function initializeChat() {
const userToken = await getToken();
chatClient = StreamChat.getInstance(appKey);
await chatClient.connectUser(
{
id: userId,
name: userName,
},
userToken
);
...
}
onDestroy(() => {
if (chatClient) chatClient.disconnectUser();
});
</script>
Here, we are setting up the core functionality of our chat application. We import the necessary Svelte lifecycle method (onDestroy) and the Stream Chat client library. You’ll need your Stream App Access Key from your app dashboard to get started.
We initialize our state variables for the chat client, user name, and user ID. Then we have four functions:
generateUserId
generates a user ID based on the name entered by the user. This isn’t strictly necessary, but if you have to implement unique user IDs, this is a useful utility.- The
getToken
function handles authentication via token retrieval. More on that in a second. - The
initializeChat
function serves as our main setup function, connecting the user to the chat service. onDestroy
ensures we clean up our connection when the component is destroyed.
You can only generate authentication tokens on a server with Stream. Our Svelte code is running in the client, so we need some separate server code to generate tokens for our users. It is this server endpoint that getToken
calls. This server could be in any language, but keeping with the JS theme, we’ll build a quick Node server using Express.
Create a new directory outside of your Svelte app, then add this to an app.js file:
//app.js
import express from "express";
import { StreamChat } from "stream-chat";
import dotenv from "dotenv";
import cors from "cors";
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json());
const streamChat = StreamChat.getInstance(
process.env.STREAM_API_KEY,
process.env.STREAM_API_SECRET
);
app.post("/token", (req, res) => {
console.log("Request body:", req.body); // Add this line to log the request body
const { userId } = req.body;
console.log("User ID:", typeof userId);
if (userId) {
const token = streamChat.createToken(userId);
res.json({ token });
} else {
res.status(401).json({ error: "User ID is required" });
}
});
app.listen(3000, () => console.log("Server running on port 3000"));
In this code, we're setting up a simple Express server that handles token generation for our chat application. We import necessary dependencies, including:
express
for our web serverstream-chat
for generating tokensdotenv
for environment variable managementcors
to handle cross-origin requests.
We can install these using:
npm install express stream-chat dotenv cors
The server exposes a single POST
endpoint at /token that expects a userId
in the request body. Stream's chat SDK creates a user token when a request comes in using the provided user ID and the Stream API secret (which should never be exposed to the client. Again, you can find this in your app dashboard). This token is then sent back to the client, allowing secure authentication with Stream Chat.
In our Svelte code, after calling getToken()
, we instantiate the chatClient
with our app key, then create a user with connectUser()
, passing the name
, ID
, and our newly minted token.
We now have a user and chat client set up to use. The template section then creates the user interface:
12345678910111213141516171819202122//src/lib/Chat.svelte {#if !chatClient} <div class="login-container"> <input bind:value={userName} placeholder="Enter your name" class="name-input" /> <button on:click={() => { userId = generateUserId(userName); initializeChat(); }} class="join-button" > Join Chat </button> </div> {:else if chatClient && channel} ... {/if}
Here, we are creating a conditional UI that shows either a login form or the chat interface (which we’ll add in a second). When no chat client exists, we display a simple login form with an input field for the user's name and a "Join Chat" button. The input uses Svelte's two-way binding (bind:value)
to sync with our userName
state, while the button click generates a user ID and initializes the chat. The {:else if}
block will contain our main chat interface once we implement it.
Finally, we have the style section with component-scoped CSS:
1234567891011121314151617181920//src/lib/Chat.svelte <style> .login-container { padding: 1rem; } .name-input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 0.5rem; width: 100%; } .join-button { margin-top: 0.5rem; padding: 0.5rem 1rem; background-color: var(--color-primary); color: var(--color-primary-foreground); border-radius: 0.5rem; } </style>
Here, we are defining component-scoped styles that apply only to our Chat component, taking advantage of Svelte's built-in CSS scoping. We're creating a clean, modern look for our login interface with consistent padding, border radius, and colors. The styles use CSS variables (var(--color-primary))
for theming, making it easy to customize the appearance across the application. The scoped nature of these styles ensures they won't leak out and affect other components.
All that gets us this:
It may not seem like a lot, but we now know a lot is going on under the hood. But when we press “Join Chat,” nothing happens. We need the next step in our functionality.
Building the Chat Interface & Functionality
Now a user can be authenticated, and the actual client is set up, we want to allow the authenticated user to use the client. Let’s add that code to our Chat.svelte file. First, the script elements:
123456789101112131415161718192021222324252627282930313233343536373839404142434445//src/lib/Chat.svelte <script> ... let channel; let messages; let newMessage = ''; ... async function initializeChat() { //...previous code...// await fetch('http://localhost:3004/make-admin', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ userId }), }); channel = chatClient.channel('messaging', 'svelte-channel', { name: 'Svelte Channel', }); const state = await channel.watch(); console.log('state', state); const response = await channel.query({ messages: { limit: 30 } }); messages = response.messages; channel.on('message.new', event => { messages = [...messages, event.message]; }); } //...previous code...// async function sendMessage() { if (newMessage.trim() && channel) { await channel.sendMessage({ text: newMessage }); newMessage = ''; } } </script>
We first set up some variables for the channel and messages, the rest of the new code is within the initializeChat
function. We first make our user an admin through a server endpoint. Stream prioritizes permissions and roles, so not just any user can create channels, and users can’t change roles on the client. Within our express server, we just add another endpoint:
12345678910111213141516//app.js app.post("/make-admin", async (req, res) => { const { userId } = req.body; if (!userId) { return res.status(400).json({ error: "User ID is required" }); } try { await streamChat.upsertUser({ id: userId, role: "admin" }); res.json({ message: "User has been made an admin" }); } catch (error) { console.error("Error making user an admin:", error); res.status(500).json({ error: "Failed to make user an admin" }); } });
This uses the upsertUser
function to change the role of the user to an admin (promoting a user to an admin for this tutorial breaks a cardinal rule of authorization – principle of least privilege – so be careful managing the roles and permissions).
Once our channel connection is established, we load the last 30 messages using channel.query()
and store them in our reactive messages state.
We also set up a real-time listener using channel.on('message.new', ...)
that updates our messages array whenever a new message arrives. The sendMessage
function handles message submission, sending the message content to the channel and clearing the input field afterward. This uses Stream's chat SDK while taking advantage of Svelte's reactivity system–when messages update, our UI will automatically re-render to show the new message.
Then the template:
123456789101112131415161718192021222324252627282930313233343536//src/lib/Chat.svelte {#if !chatClient} //...previous code...// {:else if chatClient && channel} <div class="messages-container"> {#each messages as message (message.id)} <div class="message-box"> <p class="user-name">{message.user.name}</p> <p class="message-text">{message.text}</p> </div> {/each} </div> <form on:submit|preventDefault={sendMessage} class="message-form"> <div class="input-container"> <input bind:value={newMessage} placeholder="Type a message" class="message-input" /> <button type="submit" class="send-button">Send</button> </div> </form> {:else} <div class="loading-container"> <p class="loading-text">Loading chat...</p> </div> {/if} When we have both a `chatClient` and channel established, we display the main chat interface consisting of two parts: a messages container and a message input form. The messages container uses Svelte's `{#each}` block to iterate over our array, displaying each message with the sender's name and text. Each message is keyed by its `ID` for optimal rendering performance. The message input form uses Svelte's event handling with the `preventDefault` modifier to stop form submission from refreshing the page, and two-way binding on the input field keeps it in sync with our `newMessage` state. Finally, if we have a `chatClient` but no channel yet, we show a loading state to give feedback to the user during initialization. At the end we have a bunch of styling: ```javascript //src/lib/Chat.svelte
Now, when we hit Join Chat, Alice can join the chat and start talking:
Of course, it wouldn’t be much of a chat if someone else wasn’t talking. We can open a new window, type in the name of another user, and chat along:
<video autoplay loop muted controls="false" style="border-radius: 16px; box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.3);">
<source src="https://stream-blog.s3.amazonaws.com/blog/wp-content/uploads/806abbbf64d61484c5188cc07eca005f/two_window_chat.mp4"; alt="
Two chat windows">
Two chat windows
</video>
And that is a chat app implemented in a single Svelte component with easy-to-understand vanilla JavaScript, templating, and CSS.
Improving Your Chat Application
This is a good but basic chat implementation. How could you take it to another level? Stream's chat SDK provides several features that can make your chat more engaging and functional:
- Message Threading. [Threads](https://getstream.io/chat/docs/react/threads/?language=javascript "Threads") allow users to create focused discussions within the main conversation.
- Read Receipts. Show when other users have seen messages.
- Giphy Integration. Stream has built-in Giphy support that's easy to enable.
- File Uploads. Enable users to [share files](https://getstream.io/chat/docs/react/file_uploads/?language=javascript&q=read%20receipts "share files") and images.
Typing Indicators: Show when users are [typing messages](https://getstream.io/chat/docs/react/typing_indicators/?language=javascript&q=read%20receipts "typing messages").
Let’s quickly add the last of these, typing indicators, to our chat. We just need to make a few changes for this to work:
{#if !chatClient}
{:else if chatClient && channel}
{:else}
{/if}
We’re adding the in-built Stream listeners for typing and then adding a typing indicator to our UI. So, we get this:
The Svelte Path to Production-Ready Chat
Svelte is a great opportunity for building robust, reactive chat applications. Without the extra burdens and configuration that other JS frameworks make you work through, Svelte lets you focus on what matters – building features that users actually care about. Its compile-time approach, intuitive reactivity system, and clean component syntax make it an excellent choice for real-time applications like chat, where performance and code maintainability are crucial.
Combined with a service like Stream for the heavy lifting of message handling, presence detection, and real-time updates, you can go from concept to production-ready chat in remarkably little time.