Build a Lightning-Fast Real-Time App with Astro and Convex (The Ultimate Developer Experience)
Learn how to build blazing-fast real-time applications using Astro's static site generation with Convex's powerful backend-as-a-service. Complete tutorial with working code examples, deployment guide, and best practices.

Astro and Convex
Part 1 of 2
Table of Contents
- 🤔 What Makes This Stack So Special?
- 🎯 What We’re Building Today
- 🛠️ Prerequisites & Setup
- 🚀 Step 1: Create Your Astro Project
- đź”§ Step 2: Install and Configure Convex
- 📊 Step 3: Design Your Database Schema
- 🔨 Step 4: Create Backend Functions
- ⚛️ Step 5: Create the Convex Provider for Astro
- 🎨 Step 6: Build the Chat Interface Components
- 🏗️ Step 7: Create the Main Layout
- 📱 Step 8: Build the Main Chat Page
- ⚙️ Step 9: Configure Environment Variables
- 🚀 Step 10: Run Your Application
- đź”§ Advanced Patterns & Best Practices
- 🎓 What You’ve Learned
- 🚀 Next Steps & Enhancements
- đź’ˇ Why This Stack is Perfect for Modern Apps
- 🏆 Conclusion
Hey there, modern web developers! 🚀 Ready to build applications that are both lightning-fast AND real-time? Today we’re combining two absolute powerhouses: Astro for blazing-fast static sites and Convex for real-time backend magic. By the end of this tutorial, you’ll have a production-ready chat application that loads instantly and updates in real-time across all connected users.
No more choosing between speed and interactivity - we’re getting both! ⚡
🤔 What Makes This Stack So Special?
Before we dive into the code, let’s understand why Astro + Convex is such a killer combination:
Why Astro is Perfect for Modern Apps
Astro isn’t just another JavaScript framework - it’s a paradigm shift. While other frameworks ship megabytes of JavaScript to your users, Astro ships mostly HTML with JavaScript only where you need it.
- Zero JavaScript by default - Your pages load instantly with pure HTML/CSS
- Islands Architecture - Interactive components are hydrated independently
- Framework agnostic - Use React, Vue, Svelte, or plain JavaScript components
- Partial hydration - Only the interactive parts use JavaScript
- Built-in optimizations - Image optimization, CSS bundling, and more out of the box
Why Convex is a Backend Game-Changer
Convex solves the hardest problems in backend development with an elegant approach that feels like magic:
- Real-time by default - Every query automatically becomes a live subscription
- Strong consistency - No more race conditions or data inconsistencies
- TypeScript everywhere - End-to-end type safety from database to frontend
- Serverless and scalable - Zero infrastructure management required
- ACID transactions - Your data stays consistent even under heavy load
Think of Convex as “Firebase done right” - all the real-time capabilities you love, but with proper consistency guarantees and a better developer experience.
🎯 What We’re Building Today
We’re creating a real-time chat application that showcases the best of both worlds:
- Lightning-fast initial page load (thanks to Astro)
- Real-time message updates across all connected users
- Type-safe API from database to frontend
- Production-ready deployment on modern hosting platforms
- Beautiful, responsive UI with Tailwind CSS
🛠️ Prerequisites & Setup
Before we start coding, make sure you have:
- Node.js 18+ installed on your machine
- Basic TypeScript knowledge (we’ll explain the Convex-specific parts)
- A GitHub account (for Convex authentication)
- About 45 minutes of focused coding time
New to TypeScript?
Don’t worry! The TypeScript we’ll use is straightforward, and Convex provides excellent type safety that actually makes development easier, not harder.
🚀 Step 1: Create Your Astro Project
Let’s start by creating a new Astro project with all the features we need:
# Create a new Astro project
npm create astro@latest astro-convex-chat
# When prompted, choose:
# - "Empty" template
# - Yes to TypeScript
# - Yes to install dependencies
# - Yes to initialize git repository
# Navigate to your project
cd astro-convex-chat
Now let’s add React integration for our interactive components:
# Add React support to Astro
npx astro add react
# Add Tailwind CSS for styling
npx astro add tailwind
# Install additional utilities we'll need
npm install npm-run-all clsx
Pro Tip
Astro’s integrations are incredibly smooth. The npx astro add
commands automatically configure everything for you, including TypeScript types and build configurations.
đź”§ Step 2: Install and Configure Convex
Now for the magic - let’s add Convex to our project:
# Install Convex
npm install convex
# Initialize Convex (this will prompt you to sign in with GitHub)
npx convex dev
During the Convex setup process:
- Sign in with GitHub - Convex uses GitHub for authentication
- Create a new project - Name it something like “astro-chat-app”
- Accept the default configuration - Convex will create a
convex/
folder
This creates several important files:
convex/
folder - Where your backend functions live.env.local
- Contains your Convex deployment URLconvex/_generated/
- Auto-generated TypeScript types
Keep convex dev Running
Make sure to keep the npx convex dev
command running throughout development. It watches your backend functions and keeps everything in sync.
📊 Step 3: Design Your Database Schema
Convex uses a schema-first approach that gives us incredible type safety. Let’s define our chat app’s data structure in convex/schema.ts
:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
// Users table to store user information
users: defineTable({
name: v.string(),
email: v.optional(v.string()),
avatar: v.optional(v.string()),
}).index("by_email", ["email"]),
// Messages table for chat messages
messages: defineTable({
author: v.string(),
body: v.string(),
timestamp: v.number(),
}).index("by_timestamp", ["timestamp"]),
// Rooms table for different chat rooms (future enhancement)
rooms: defineTable({
name: v.string(),
description: v.optional(v.string()),
isPrivate: v.boolean(),
}),
});
What’s happening here?
- defineSchema creates our database schema with type safety
- defineTable defines individual tables with their fields
- v.string(), v.number() are Convex’s type validators
- v.optional() makes fields optional
- .index() creates database indexes for efficient queries
The indexes are crucial for performance - by_timestamp
lets us efficiently query messages in chronological order.
🔨 Step 4: Create Backend Functions
Now let’s create the backend functions that power our chat app. In Convex, you write functions that run on the server and are automatically exposed as APIs.
Message Functions
Create convex/messages.ts
:
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
// Query to get all messages (with real-time updates!)
export const getMessages = query({
args: {},
handler: async (ctx) => {
// Get the last 50 messages, ordered by timestamp
const messages = await ctx.db
.query("messages")
.withIndex("by_timestamp")
.order("desc")
.take(50);
// Return them in chronological order (oldest first)
return messages.reverse();
},
});
// Mutation to send a new message
export const sendMessage = mutation({
args: {
author: v.string(),
body: v.string()
},
handler: async (ctx, args) => {
// Validate input
if (!args.author.trim()) {
throw new Error("Author name is required");
}
if (!args.body.trim()) {
throw new Error("Message cannot be empty");
}
// Insert the message with current timestamp
await ctx.db.insert("messages", {
author: args.author.trim(),
body: args.body.trim(),
timestamp: Date.now(),
});
},
});
// Query to get message count (for stats)
export const getMessageCount = query({
args: {},
handler: async (ctx) => {
const messages = await ctx.db.query("messages").collect();
return messages.length;
},
});
Key Concepts Explained:
- query functions can only read data and automatically provide real-time updates
- mutation functions can modify data and run as atomic transactions
- ctx.db gives you access to your database with full type safety
- withIndex() uses our predefined indexes for efficient queries
- Error handling is built-in - thrown errors are automatically sent to the client
User Functions
Create convex/users.ts
for user management:
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
// Get or create a user
export const getOrCreateUser = mutation({
args: {
name: v.string(),
email: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Check if user already exists
let user = null;
if (args.email) {
user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
}
// Create new user if not found
if (!user) {
const userId = await ctx.db.insert("users", {
name: args.name,
email: args.email,
});
user = await ctx.db.get(userId);
}
return user;
},
});
// Get online users count
export const getActiveUsersCount = query({
args: {},
handler: async (ctx) => {
const users = await ctx.db.query("users").collect();
return users.length;
},
});
⚛️ Step 5: Create the Convex Provider for Astro
Astro’s component islands need a way to connect to Convex. Let’s create a provider wrapper in src/lib/convex.tsx
:
import { CONVEX_URL } from "astro:env/client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { type FunctionComponent, type JSX } from "react";
const client = new ConvexReactClient(CONVEX_URL);
// Astro context providers don't work when used in .astro files.
// See this and other related issues: https://github.com/withastro/astro/issues/2016#issuecomment-981833594
//
// This exists to conveniently wrap any component that uses Convex.
export function withConvexProvider<Props extends JSX.IntrinsicAttributes>(
Component: FunctionComponent<Props>,
) {
return function WithConvexProvider(props: Props) {
return (
<ConvexProvider client={client}>
<Component {...props} />
</ConvexProvider>
);
};
}
🎨 Step 6: Build the Chat Interface Components
Now let’s create our React components for the chat interface. These will be used as Astro islands.
Message List Component
Create src/components/MessageList.tsx
:
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import { withConvexProvider } from "../lib/convex";
import { clsx } from "clsx";
function MessageListComponent() {
const messages = useQuery(api.messages.getMessages);
const messageCount = useQuery(api.messages.getMessageCount);
if (messages === undefined) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-gray-500">Loading messages...</p>
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Chat header with stats */}
<div className="text-center text-sm text-gray-500 mb-6">
{messageCount} messages in this chat
</div>
{messages.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">đź’¬</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No messages yet
</h3>
<p className="text-gray-500">
Be the first to start the conversation!
</p>
</div>
) : (
<div className="space-y-3">
{messages.map((message) => (
<div
key={message._id}
className={clsx(
"max-w-xs lg:max-w-md px-4 py-2 rounded-2xl",
"bg-blue-500 text-white ml-auto"
)}
>
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium opacity-90">
{message.author}
</span>
<span className="text-xs opacity-75">
{new Date(message.timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<p className="text-sm">{message.body}</p>
</div>
))}
</div>
)}
</div>
);
}
// Export the wrapped component as default
const MessageList = withConvexProvider(MessageListComponent);
export default MessageList;
Message Input Component
Create src/components/MessageInput.tsx
:
import { useMutation } from "convex/react";
import { useState, useRef, useEffect } from "react";
import { api } from "../../convex/_generated/api";
import { withConvexProvider } from "../lib/convex";
import { clsx } from "clsx";
function MessageInputComponent() {
const sendMessage = useMutation(api.messages.sendMessage);
const [author, setAuthor] = useState("");
const [body, setBody] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const messageInputRef = useRef<HTMLInputElement>(null);
// Load author name from localStorage
useEffect(() => {
const savedAuthor = localStorage.getItem("chat-author-name");
if (savedAuthor) {
setAuthor(savedAuthor);
}
}, []);
// Save author name to localStorage when it changes
useEffect(() => {
if (author) {
localStorage.setItem("chat-author-name", author);
}
}, [author]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!author.trim() || !body.trim()) {
setError("Please enter both your name and a message");
return;
}
setIsLoading(true);
try {
await sendMessage({
author: author.trim(),
body: body.trim()
});
// Clear message input and focus it
setBody("");
messageInputRef.current?.focus();
} catch (err) {
console.error("Failed to send message:", err);
setError(err instanceof Error ? err.message : "Failed to send message");
} finally {
setIsLoading(false);
}
};
return (
<div className="border-t bg-white p-4">
{error && (
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-3">
{/* Author name input */}
<div>
<input
type="text"
placeholder="Your name"
value={author}
onChange={(e) => setAuthor(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isLoading}
/>
</div>
{/* Message input */}
<div className="flex gap-2">
<input
ref={messageInputRef}
type="text"
placeholder="Type your message..."
value={body}
onChange={(e) => setBody(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !author.trim() || !body.trim()}
className={clsx(
"px-6 py-2 rounded-lg font-medium transition-colors",
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
isLoading || !author.trim() || !body.trim()
? "bg-gray-300 text-gray-500 cursor-not-allowed"
: "bg-blue-500 text-white hover:bg-blue-600"
)}
>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Sending...
</div>
) : (
"Send"
)}
</button>
</div>
</form>
</div>
);
}
// Export the wrapped component as default
const MessageInput = withConvexProvider(MessageInputComponent);
export default MessageInput;
🏗️ Step 7: Create the Main Layout
Create src/layouts/ChatLayout.astro
:
---
import '../styles/global.css'
export interface Props {
title: string;
}
const { title } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Real-time chat built with Astro and Convex" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body class="bg-gray-50 min-h-screen">
<slot />
</body>
</html>
📱 Step 8: Build the Main Chat Page
Update src/pages/index.astro
:
---
import ChatLayout from '../layouts/ChatLayout.astro';
import MessageList from '../components/MessageList';
import MessageInput from '../components/MessageInput';
---
<ChatLayout title="Astro + Convex Chat">
<div class="min-h-screen flex flex-col">
<!-- Header -->
<header class="bg-white shadow-sm border-b">
<div class="max-w-4xl mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">
⚡ Astro + Convex Chat
</h1>
<p class="text-sm text-gray-600">
Lightning-fast real-time messaging
</p>
</div>
<div class="flex items-center gap-2 text-sm text-gray-500">
<div class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span>Live</span>
</div>
</div>
</div>
</header>
<!-- Chat Container -->
<main class="flex-1 max-w-4xl mx-auto w-full bg-white shadow-lg flex flex-col">
<MessageList client:load />
<MessageInput client:load />
</main>
<!-- Footer -->
<footer class="bg-gray-100 border-t">
<div class="max-w-4xl mx-auto px-4 py-3">
<p class="text-center text-sm text-gray-600">
Built with
<a href="https://astro.build" class="text-blue-600 hover:underline">Astro</a>
and
<a href="https://convex.dev" class="text-blue-600 hover:underline">Convex</a>
</p>
</div>
</footer>
</div>
</ChatLayout>
The client:load Directive
The client:load
directive tells Astro to hydrate these React components on the client side. This gives us the interactivity we need while keeping the initial page load fast.
⚙️ Step 9: Configure Environment Variables
Update your astro.config.mjs
to handle environment variables properly:
// @ts-check
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, envField } from "astro/config";
// https://astro.build/config
export default defineConfig({
integrations: [react()],
env: {
schema: {
CONVEX_URL: envField.string({
access: "public",
context: "client",
}),
},
},
vite: {
plugins: [tailwindcss()],
},
});
Create or update .env.local
to ensure your Convex URL is accessible:
# Your Convex deployment URL (auto-generated by convex dev)
CONVEX_URL=https://your-deployment.convex.cloud
PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
🚀 Step 10: Run Your Application
Now let’s see your creation in action! Update your package.json
scripts:
{
"scripts": {
"dev": "run-p dev:*",
"dev:astro": "astro dev",
"dev:convex": "convex dev",
"build": "astro build",
"preview": "astro preview",
"convex": "convex"
}
}
Start your development environment:
# This runs both Astro and Convex in parallel
npm run dev
Open your browser to http://localhost:4321
and you should see your chat app! 🎉
Testing Real-Time Updates
Open multiple browser tabs or windows to see the real-time magic in action. Messages sent from one tab will instantly appear in all other tabs!
đź”§ Advanced Patterns & Best Practices
Error Handling
Implement robust error handling in your components:
// In your components
const [error, setError] = useState<string | null>(null);
try {
await sendMessage({ author, body });
} catch (err) {
if (err instanceof ConvexError) {
setError(err.data);
} else {
setError("Something went wrong. Please try again.");
}
}
Performance Optimization
- Use Astro’s partial hydration - Only hydrate interactive components
- Implement pagination for large message lists
- Add debouncing for real-time features like typing indicators
- Use Convex’s built-in caching - Queries are automatically cached and invalidated
Security Best Practices
- Input validation - Always validate data in your Convex functions
- Rate limiting - Implement rate limiting for message sending
- Content moderation - Add filters for inappropriate content
- Authentication - Implement proper user authentication for production apps
🎓 What You’ve Learned
Congratulations! You’ve just built a production-ready real-time application using cutting-edge technologies. Here’s what you’ve mastered:
- Astro’s Islands Architecture - Fast loading with selective interactivity
- Convex’s Real-Time Database - Automatic synchronization across clients
- Type-Safe Development - End-to-end TypeScript with auto-generated types
- Modern React Patterns - Hooks, error handling, and performance optimization
- Deployment Strategies - Static site hosting with serverless backend
🚀 Next Steps & Enhancements
Ready to take your app to the next level? Here are some exciting features to add:
Message Threading File Uploads User Authentication Push NotificationsAdvanced Features to Implement:
- User Authentication with Convex Auth for secure login
- Multiple Chat Rooms with real-time room switching
- File Sharing using Convex’s built-in file storage
- Message Search with full-text search capabilities
- Typing Indicators showing when users are typing
- Message Reactions with emoji support
- Push Notifications for mobile users
- Message Threading for organized conversations
Production Considerations:
- Rate Limiting to prevent spam and abuse
- Content Moderation with automated filtering
- Analytics Integration for user insights
- Error Monitoring with tools like Sentry
- Performance Monitoring to track real-world usage
- Backup Strategies for critical data
- Load Testing to ensure scalability
đź’ˇ Why This Stack is Perfect for Modern Apps
The Astro + Convex combination gives you superpowers that were previously impossible:
Astro Benefits:
- Instant page loads with minimal JavaScript
- SEO-friendly static site generation
- Framework flexibility - use any UI library
- Automatic optimizations for images, CSS, and more
Convex Benefits:
- Real-time everything without complex WebSocket management
- Strong consistency prevents data corruption and race conditions
- Serverless scalability from prototype to millions of users
- TypeScript integration catches bugs before they reach production
Together, they provide:
- Best-in-class performance - Fast initial loads AND real-time updates
- Developer experience that makes building complex apps feel simple
- Production readiness with built-in scalability and reliability
- Cost effectiveness - pay only for what you use
Perfect for Startups
This stack is ideal for startups and side projects because you can build and deploy rapidly without worrying about infrastructure, scaling, or complex backend management.
🏆 Conclusion
You’ve just built something pretty amazing! A real-time chat application that:
- Loads instantly thanks to Astro’s static generation
- Updates in real-time across all connected users
- Scales automatically with Convex’s serverless architecture
- Maintains data consistency even under heavy load
- Provides excellent developer experience with end-to-end type safety
This is just the beginning. The patterns and concepts you’ve learned here apply to countless other applications:
- Collaborative tools (think Google Docs)
- Live dashboards with real-time metrics
- Gaming applications with live leaderboards
- E-commerce sites with live inventory updates
- Social platforms with instant notifications
The web is moving towards real-time, interactive experiences, and you now have the tools to build them efficiently and reliably.
Star This Tutorial on GitHubWhat’s next? Try building your own real-time application! Whether it’s a collaborative todo app, a live polling system, or a multiplayer game, you now have the foundation to create amazing user experiences.
Happy coding!
Related Posts

Setting up Plausible Analytics for Astro with CloudFlare Workers
Learn how to set up a proxy for Plausible Analytics using Cloudflare Workers, a free service that allows you to count your website stats without third-party requests

Best Astro.js Online Courses/Tutorials
If you want to learn Astro.js you should check these online courses that can help you get started.

Coolify Install A Free Heroku and Netlify Self-Hosted Alternative
Free Heroku and Netlify alternative? Coolify Install is an easy-to-use self-hosted platform that will help you get started quickly, without any complicated setup.