In this comprehensive, step-by-step guide, you will build a fully type-safe, production-ready, real-time chat application. We'll leverage the power of Igniter.js and its native Server-Sent Events (SSE) support for instant message delivery. The stack includes Next.js (App Router) for the frontend, Prisma as our ORM, and PostgreSQL for the database.
You'll discover how Igniter.js accelerates development with its CLI, automatic code generation, and a beautifully simple set of hooks for data fetching and real-time communication.
Check out the live demo: igniter-js-sample-realtime-chat.vercel.app
In modern web applications, real-time features are no longer a luxury; they're an expectation. Whether it's live notifications, collaborative editing, or a simple chat room, users expect to see updates instantly without hitting a refresh button.
While WebSockets have traditionally been the go-to solution for bi-directional communication, many "real-time" features are actually server-to-client dominant. A chat application is a perfect example: a user sends a message (one client-to-server request), and the server then pushes that message out to all connected clients.
This is where Server-Sent Events (SSE) shine. SSE is a web standard that allows a server to push data to a client over a single, long-lived HTTP connection. It's simpler, lighter, and often more efficient than WebSockets for server-push use cases.
Igniter.js embraces SSE as a first-class citizen, offering:
useRealtime
hook abstracts away all the complexity of managing an SSE connection. You simply subscribe to a named event and provide a callback.revalidate
mechanism. When a mutation happens on the backend (like sending a message), you can tell the server to notify all clients to refetch their data. This updates the UI automatically, often eliminating the need for manual client-side state management for real-time updates.This tutorial follows the official sample-realtime-chat
application. You can clone it to see the final result or follow along step-by-step to build it from scratch.
Before we begin, ensure you have the following installed:
npm
or yarn
.The easiest way to get a database running is with Docker. The sample repository includes a docker-compose.yml
file for PostgreSQL. If you're starting from scratch, you can create a simple one to spin up a Postgres instance.
The fastest way to start a new Igniter.js project is with the official CLI. It scaffolds a new application based on a chosen template, setting up all the necessary boilerplate for you.
Open your terminal and run the igniter init
command:
npx @igniter-js/cli@latest init
The CLI will prompt you to choose a template. For this tutorial, select the Next.js App Router template. This will create a new directory with a fully configured Next.js project, including Igniter.js, TypeScript, and Tailwind CSS.
Once the command finishes, navigate into your new project directory:
cd your-project-name
npm run dev
This installs all the dependencies for your new monorepo-ready application.
A schema-first approach is a cornerstone of robust application development. By defining our data models first, we create a single source of truth that will drive our API generation and ensure type safety.
Find the prisma/schema.prisma
file in your project and replace its content with our chat application's models: User
and Message
.
// ./prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Message {
id String @id @default(uuid())
content String
sender String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("messages") // Maps the model to the "messages" table in the database
}
Next, create a .env
file in the root of your project and add your PostgreSQL connection string.
# ./.env
DATABASE_URL="postgresql://user:password@localhost:5432/igniter_chat?schema=public"
With the schema and environment variable in place, run the Prisma db push
command to sync your schema with your database. This will create the User
and Message
tables.
npx prisma migrate dev --name init
This is where the magic of Igniter.js's CLI tooling comes into play. Instead of manually writing controllers, validation logic, and type definitions for our Message
model, we can use the igniter generate
command to do it for us.
The generator introspects your schema.prisma
file and scaffolds a complete feature slice based on a model. Run the following command in your terminal:
npx @igniter-js/cli generate feature message --schema prisma:Message
What just happened? The Igniter CLI read your Message
model from schema.prisma
and generated a complete set of CRUD (Create, Read, Update, Delete) operations for it. This includes:
message.controller.ts
file with query and mutation actions.message.procedure.ts
file containing reusable business logic functions (similar to a service or repository layer). This helps keep your controller lean and promotes code reuse across your application.This command just saved you hours of boilerplate work and ensured your API is perfectly in sync with your database schema.
The generated files will be placed in src/features/message/
. Take a moment to inspect src/features/message/controllers/message.controller.ts
. You'll see that it has already created actions for listing, creating, and finding messages.
For our chat application, we need to add one more action: a real-time stream. Open the generated controller and add the stream
action to it.
// src/features/message/controllers/message.controller.ts
import { igniter } from '@/igniter'
import { z } from 'zod'
import { messageCreateSchema, messageUpdateSchema } from '../schema/message.schema'
export const messageController = igniter.controller({
path: '/messages',
actions: {
// ... existing generated actions (list, find, create, etc.)
// Update this new action for our real-time channel
list: igniter.query({
name: 'list',
description: 'List all Messages',
path: '/',
stream: true, // Add this flag tells Igniter this is an SSE endpoint
use: [messageProcedure()],
handler: async ({ context, response }) => {
const records = await context.messageRepository.findAll()
return response.success(records)
},
}),
},
})
We also need to slightly modify the create
action. After a new message is created, we need to do two things:
message.list
query to ensure that anyone who re-fetches the message history gets the new message.// src/features/message/controllers/message.controller.ts
// ... imports
export const messageController = igniter.controller({
// ...
actions: {
list: igniter.query({
// ...
}),
create: igniter.mutation({
path: '/',
method: 'POST',
body: z.object({ text: z.string().min(1) }), // Keep it simple for the chat
handler: async ({ request, context, response }) => {
// 1. Call create method
const newRecord = await context.messageRepository.create(request.body)
// 2. Invalidate the cache for the 'list' query and publish the new message to all clients listening to this stream
return response.created(created).revalidate(['message.list'])
},
}),
},
})
The generated controller is ready, but the application doesn't know about it yet. We need to register it in our main API router.
Open src/igniter.router.ts
and import the messageController
. Then, add it to the controllers
object.
// src/igniter.router.ts
import { igniter } from '@/igniter'
import { messageController } from '@/features/message/controllers/message.controller'
export const AppRouter = igniter.router({
controllers: {
// Register our new controller under the 'message' key
message: messageController,
},
})
export type AppRouter = typeof AppRouter
That's it! The entire backend API for our chat application is now complete and fully functional.
Before building the frontend, it's a great practice to confirm the API is working as expected. You can do this easily with a tool like cURL
.
If you're using tools like Cursor, Windsurf, GitHub Copilot, Claude Code, or similar, Igniter.js comes starter-ready with specific training for Lia. She deeply understands how to use Igniter.js: from operating the CLI, creating features, configuring framework processes, creating jobs, writing tests, and much more. This means you can simply ask Lia to test routes, generate cURL commands, create automated tests, or even scaffold entire features — all as part of the framework's AI-friendly workflow.
Here are the commands to test sending and listing messages:
1. Send a new message:
curl -X POST http://localhost:3000/api/v1/messages \
-H "Content-Type: application/json" \
-d '{"text": "Hello from cURL!"}'
2. List all messages:
curl http://localhost:3000/api/v1/messages
You should see the message you just created in the response.
With a robust backend in place, it's time to build the user interface. We'll create a single React component, Chat.tsx
, to handle everything.
Create a new file at src/features/message/presentation/chat.tsx
.
// src/features/message/presentation/chat.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import { api } from '@/igniter.client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Send } from 'lucide-react'
export function Chat() {
const [sender, setSender] = useState('')
const [message, setMessage] = useState('')
const [isSenderSet, setIsSenderSet] = useState(false)
const scrollAreaRef = useRef<HTMLDivElement>(null)
const { data } = api.message.list.useQuery({
refetchOnWindowFocus: false,
})
const messages = data || []
const createMessage = api.message.create.useMutation()
useEffect(() => {
const storedSender = localStorage.getItem('chat-sender')
if (storedSender) {
setSender(storedSender)
setIsSenderSet(true)
}
}, [])
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault()
if (message.trim() && sender.trim()) {
await createMessage.mutate({
body: {
content: message,
sender: sender,
},
})
setMessage('')
}
}
const handleSetSender = () => {
if (sender.trim()) {
localStorage.setItem('chat-sender', sender.trim())
setIsSenderSet(true)
}
}
useEffect(() => {
if (scrollAreaRef.current) {
const scrollElement = scrollAreaRef.current.querySelector('div > div')
if (scrollElement) {
scrollElement.scrollTo({
top: scrollElement.scrollHeight,
behavior: 'smooth',
})
}
}
}, [messages])
return (
<div className="relative flex flex-col">
<Card className="w-full rounded-none pt-0 border-none flex-1 flex flex-col">
<CardHeader className="border-b !pt-4 !pb-2 px-4">
<CardTitle>Real-time Chat with Igniter.js</CardTitle>
</CardHeader>
<CardContent className='px-4 py-0'>
<ScrollArea className=" w-full h-[calc(100vh-16.5rem)] pb-10 relative" ref={scrollAreaRef}>
<div className="space-y-4">
{messages.map((msg: any) => (
<div
key={msg.id}
className={`flex ${
msg.sender === sender ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`p-3 rounded-lg max-w-xs ${
msg.sender === sender
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}
>
<p className="text-sm font-bold">{msg.sender}</p>
<p>{msg.content}</p>
<p className="text-xs text-right opacity-70 mt-1">
{new Date(msg.createdAt).toLocaleTimeString()}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
<form
onSubmit={handleSendMessage}
className="flex w-full items-center space-x-2"
>
<Input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type a message..."
disabled={!isSenderSet}
/>
<Button
type="submit"
disabled={createMessage.isLoading || !isSenderSet}
>
<Send className="h-4 w-4" />
</Button>
</form>
</CardContent>
</Card>
{!isSenderSet && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Welcome!</CardTitle>
<CardDescription>
Please enter your name to join the chat.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex w-full items-center space-x-2">
<Input
value={sender}
onChange={(e) => setSender(e.target.value)}
placeholder="Enter your name..."
onKeyDown={(e) => e.key === 'Enter' && handleSetSender()}
/>
<Button onClick={handleSetSender}>Join Chat</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}
Chat.tsx
ComponentLet's break down the main hooks and logic that power this component:
api.message.list.useQuery()
This hook fetches the initial list of messages from the /api/v1/messages
endpoint. It handles loading, caching, and automatic revalidation. When a new message is sent and the backend triggers a revalidation (via .revalidate(['message.list'])
), this hook will automatically refetch the latest messages, keeping the UI in sync.
api.message.create.useMutation()
This hook provides the function to send new messages to the backend (POST /api/v1/messages
). When the user submits the form, createMessage.mutate()
is called with the message content and sender. After a successful mutation, the backend triggers a revalidation event, causing the message list to update in real time.
Local State for Sender and Message
The component uses React state to manage the sender's name and the current message being typed. The sender is persisted in localStorage
so users don't have to re-enter their name on refresh.
Automatic Scrolling After each message update, the component scrolls to the bottom of the chat window to show the latest messages.
Note: In this implementation, real-time updates rely on the automatic revalidation mechanism provided by Igniter.js. If you want to handle true push-based updates (e.g., instant message delivery without refetching the entire list), you can also use
api.message.stream.useRealtime()
to subscribe directly to SSE events from the backend.
Now, let's display our Chat
component. Open src/app/page.tsx
and replace its content:
// src/app/page.tsx
import { Chat } from '@/features/message/presentation/chat'
export default function HomePage() {
return (
<main className="container mx-auto py-8">
<h1 className="text-3xl font-bold text-center mb-8">
Igniter.js Real-Time Chat
</h1>
<Chat />
</main>
)
}
Make sure to add NEXT_PUBLIC_APP_URL
to your .env
file for production builds.
Everything is now in place. Start the development server:
bun dev
Open your browser to http://localhost:3000
. To see the real-time updates in action, open a second browser window (or a new tab) and place them side-by-side.
Type a message in one window and hit send. You'll see it appear instantly in both windows. Congratulations, you've just built a type-safe, real-time chat application!
@igniter-js/adapter-redis
) that handles this seamlessly. When you call igniter.realtime.publish()
, the Redis adapter will publish the message to a Redis channel, and all your server instances subscribed to that channel will receive it and forward it to their connected clients.@igniter-js/adapter-opentelemetry
to trace requests and real-time flows, helping you diagnose issues and monitor performance.You've just scratched the surface of what's possible with Igniter.js. Here are some resources to continue your journey:
useRealtime
HookHappy hacking!
Igniter.js provides everything you need to create production-ready applications. Start building your next project in minutes.