JavaScript is the foundation of the modern web. Without it, you don’t have the rich interaction you expect from websites, like real-time updates, video, or chat.
But JS brings a problem: bloat. Modern web apps often ship megabytes of JavaScript to the browser. This slows initial page load and hurts performance, especially for mobile devices. And this JS isn’t even required for most of the page–just the interactive elements.
This is where Astro and its Islands architecture come in. Rather than sending a single bundle of JavaScript for the entire application, Astro lets you selectively hydrate only the necessary interactive components, keeping the rest of your site as lightweight static HTML. This clever approach promises the best of both worlds: rich interactivity where you need it with minimal JavaScript overhead.
Here, you’ll build out a chat app using the Stream API and Astro, using islands for the interactive components. But first, here is a little primer on how Astro and Islands work.
Astro’s Island Architecture
Traditional single-page applications (SPAs) face a significant challenge: they typically load and hydrate the entire page with JavaScript, even when only small portions require interactivity. This approach leads to several issues:
- Initial page load performance suffers as browsers must download, parse, and execute large JavaScript bundles
- Memory usage increases unnecessarily as the entire page is hydrated
- Time-to-interactive (TTI) metrics are impacted as the browser processes JavaScript for static content
- Battery life on mobile devices is affected by the constant JavaScript execution
Astro introduces the concept of "Islands"--isolated interactive components in a sea of static HTML. Think of it like this:
Each “app” here will have its island of JS that can be selectively hydrated on the client. This gives developers fine-grained control to specify when and how islands hydrate. It also allows multiple islands to load and hydrate independently.
Let's say you have a blog post with an interactive comment section:
12345678910111213141516171819--- // BlogPost.astro import StaticHeader from '../components/Header.astro'; import CommentSection from '../components/CommentSection.jsx'; --- <StaticHeader /> <article class="prose"> <h1>Understanding Astro Islands</h1> <p>This is static content that needs no JavaScript...</p> </article> <!-- This is an interactive island --> <CommentSection client:load /> <footer> <p>© 2025 My Blog</p> </footer>
The header, article content, and footer remain lightweight HTML. Only the comment section is marked as an interactive island with the client:load
directive, meaning it will receive JavaScript hydration while the rest of the page stays static and fast.
client:load
is only one of the possible directives Astro offers for loading and hydration–you’ll use a couple more in the example below.
Beyond the performance benefits for islands–Faster initial page loads, reduced memory usage, improved core web vitals–islands also offer a better developer experience. They give devs clear boundaries between static and dynamic content and a simple mental model for development. You also gain control over hydration and can continue to use your framework of choice–React, Preact, Vue, Svelte, whatever.
Your users benefit. Pages will work without JS, and they will experience better overall performance. By being thoughtful about where and when we use JavaScript, we can create faster, more efficient web applications while maintaining rich interactivity where it matters most.
Let’s do that.
Developer Setup
To get this chat app running, first, create a new Astro project:
1npm create astro@latest
Then, install the required dependencies:
1npm install stream-chat @astrojs/react react react-dom
Once the project has been created, you’ll need to configure Astro to use React by adding the following to your astro.config.mjs:
123456import { defineConfig } from 'astro/config'; import react from '@astrojs/react'; export default defineConfig({ integrations: [react()], });
Finally, you’ll need to create a .env file in your project root and add your Stream key:
1PUBLIC_STREAM_KEY=your_public_key
Create a Stream Account
To get those keys, you'll need a Stream account. To create your free account, go to Stream's signup page.
Once you've created your account, follow these steps to set up your project:
- Log into the Stream Dashboard
- Click the "Create App" button in the top right corner
- Give your app a name (e.g., "Astro Chat App")
- Choose "Development" mode - this provides free API calls for testing
- Click "Create App" to generate your project
After creating your app, you'll land on the app dashboard, where you can find your Stream API Key to add to your .env file.
Islands & The Stream
You will create three components within the /src directory. First, the messageList
component:
123456789101112131415161718192021222324252627282930313233// src/components/MessageList.jsx import { useEffect, useRef } from 'react'; export default function MessageList({ messages }) { const messagesEndRef = useRef(null); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); return ( <div className="messages"> {messages.map((msg) => ( <div key={msg.id} className="message"> <strong>{msg.user?.name || 'Unknown'}:</strong> {msg.text} </div> ))} <div ref={messagesEndRef} /> <style>{` .messages { height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; margin-bottom: 20px; } .message { margin-bottom: 10px; } `}</style> </div> ); }
This component displays a scrollable list of chat messages, each showing the sender's name and text. It uses a ref to automatically scroll to the newest messages, ensuring users always see the latest content in the conversation.
Then, the MessageInput
component:
123456789101112131415161718192021222324252627282930313233343536373839// src/components/MessageInput.jsx import { useState } from 'react'; export default function MessageInput({ onSendMessage }) { const [message, setMessage] = useState(''); const handleSubmit = (e) => { e.preventDefault(); if (message.trim()) { onSendMessage(message); setMessage(''); } }; return ( <form onSubmit={handleSubmit}> <input type="text" value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Type your message..." /> <button type="submit">Send</button> <style>{` form { display: flex; gap: 10px; } input { flex: 1; padding: 8px; } button { padding: 8px 16px; } `}</style> </form> ); }
This component handles message input through a simple form with a text field and a submit button. It maintains its state for the message text and clears the input after submission, providing a clean interface for users to send new messages.
These components are just regular React components. The same goes for our next ChatContainer
component, but with the islands twist:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110// src/components/ChatContainer.jsx import { useState, useEffect } from 'react'; import { StreamChat } from 'stream-chat'; import MessageList from './MessageList'; import MessageInput from './MessageInput'; export default function ChatContainer() { const [client, setClient] = useState(null); const [channel, setChannel] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); const [username, setUsername] = useState(''); const [isConnected, setIsConnected] = useState(false); const connectToChat = async (e) => { e.preventDefault(); setLoading(true); try { const chatClient = StreamChat.getInstance(import.meta.env.PUBLIC_STREAM_KEY); await chatClient.connectUser( { id: username, name: username, image: `https://getstream.io/random_svg/?name=${username}`, }, chatClient.devToken(username) ); const channel = chatClient.channel('messaging', 'astro-channel', { name: 'Astro Channel', members: [username], }); await channel.watch(); channel.on('message.new', (event) => { setMessages(prev => [...prev, event.message]); }); setClient(chatClient); setChannel(channel); setMessages(channel.state.messages); setIsConnected(true); } catch (error) { console.error('Error initializing chat:', error); } finally { setLoading(false); } }; useEffect(() => { return () => { if (client) { client.disconnectUser(); } }; }, [client]); const sendMessage = async (text) => { if (channel) { await channel.sendMessage({ text, user_id: username, }); } }; if (loading && isConnected) { return <div>Loading chat...</div>; } if (!isConnected) { return ( <div className="chat-container"> <form onSubmit={connectToChat}> <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Enter your username" required /> <button type="submit">Join Chat</button> </form> <style>{` .chat-container { max-width: 600px; margin: 0 auto; padding: 20px; } `}</style> </div> ); } return ( <div className="chat-container"> <MessageList messages={messages} client:visible /> <MessageInput onSendMessage={sendMessage} client:idle /> <style>{` .chat-container { max-width: 600px; margin: 0 auto; padding: 20px; } `}</style> </div> ); }
Most of this code manages the Stream Chat connection, handling user authentication, channel creation, and message sending/receiving in a typical React way. What makes it special for Astro is the use of client:visible
and client:idle
directives–these ensure the MessageList only hydrates when visible in the viewport, while the input hydrates during browser idle time, optimizing the initial page load.
The client:
directives are the core of how Astro hydrates components:
client:load
: Hydrate immediately when the page loadsclient:visible
: Hydrate when the component enters the viewportclient:idle
: Hydrate during browser idle timeclient:media
: Hydrate based on media queriesclient:only
: Hydrate client-side only, no server rendering
You then use client:load
in our index.astro, which is the file that serves as the entry point for our application, wrapping the ChatContainer in a standard layout and marking it for immediate hydration:
123456789101112--- // index.astro import Layout from '../layouts/Layout.astro'; import ChatContainer from '../components/ChatContainer'; --- <Layout title="Stream Chat Demo"> <main> <h1>Stream Chat Demo</h1> <ChatContainer client:load /> </main> </Layout>
So, when the page loads, the ChatContainer component hydrates immediately due to the client:load
directive. In contrast, its child components follow their own hydration patterns–the MessageList waits until it's visible in the viewport, and the MessageInput hydrates during browser idle time. This creates a cascade of efficient, just-in-time hydration.
The actual chat will look no different from any other chat app (save the Astro toolbar at the bottom):
But if you inspect the network traffic for this app in the browser, you will see that each of the components is rendered as a separate, small file:
This separation of components into individual JavaScript bundles is Astro Islands in action. It allows each piece of interactivity to load independently and only when needed.
Building With The Island Pattern
The #1 benefit of islands is that they make you think about JavaScript. You need JS for your chat window, but do you need it for the headers and images around it? Do you need it in your footer? Do you need it in your blog posts?
With Astro and Islands, you ship only the JS needed, nothing more. By focusing on the selective hydration of interactive components, it offers a practical solution to JavaScript bloat without sacrificing the rich interactivity users expect. This approach not only enhances the user experience with faster page loads, reduced memory usage, and improved performance but also simplifies the developer workflow by providing clear boundaries between static and dynamic content.
The Stream Chat example demonstrates that Astro empowers developers to use their favorite frameworks while ensuring efficient resource utilization. The fine-grained control over when and how JavaScript is loaded allows you to build applications that are not only lightweight but also maintainable and scalable.
In a web ecosystem increasingly dominated by bloated single-page applications, Astro offers a refreshing alternative that embraces the core principles of the web: simplicity, speed, and accessibility. By adopting the Islands pattern, developers can rethink how they use JavaScript, delivering better user experiences and a more sustainable web for everyone. It’s time to embrace a more thoughtful approach to interactivity—one that keeps the web fast, efficient, and delightful.