A private, self-destructing realtime chat application built with Next.js, TypeScript, and Upstash.
Realtime Chat is a secure, ephemeral messaging platform where conversations automatically self-destruct after 10 minutes. Built for privacy-focused communication with realtime synchronization and optimistic UI updates.
- Self-Destructing Rooms: Chat rooms automatically expire after 10 minutes
- Realtime Messaging: Instant message delivery via Upstash Realtime pub/sub
- Secure Access: Token-based authentication limiting rooms to 2 participants
- Optimistic UI: Instant feedback with TanStack React Query
- Persistent History: Messages stored in Redis during room lifetime
- Anonymous Identity: Auto-generated usernames on first visit
| Category | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS 4 |
| State Management | TanStack React Query |
| Backend | Elysia (embedded in Next.js API routes) |
| Database | Upstash Redis |
| Realtime | Upstash Realtime |
| Validation | Zod v4 |
| Runtime | Bun |
| Utilities | nanoid, date-fns |
realtime-chat/
├── public/ # Static assets
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ └── [[...slugs]]/
│ │ │ ├── auth.ts # Elysia auth middleware
│ │ │ └── route.ts # Elysia server (rooms & messages API)
│ │ ├── realtime/
│ │ │ └── route.ts # Upstash Realtime endpoint
│ │ ├── room/
│ │ │ └── [roomId]/
│ │ │ └── page.tsx # Chat room page
│ │ ├── globals.css # Global styles
│ │ ├── layout.tsx # Root layout with providers
│ │ └── page.tsx # Home/lobby page
│ ├── components/
│ │ └── providers.tsx # React Query & Realtime providers
│ ├── constants/
│ │ └── index.ts # App constants (animals, storage keys)
│ ├── hooks/
│ │ └── use-username.ts # Username management hook
│ ├── lib/
│ │ ├── client.ts # Elysia/Eden API client
│ │ ├── index.ts # Utility functions
│ │ ├── realtime.ts # Upstash Realtime schema & types
│ │ ├── realtime-client.ts # Realtime hook exporter
│ │ └── redis.ts # Upstash Redis client
│ └── proxy.ts # Next.js middleware for auth
├── .env # Environment variables
├── package.json
└── README.md
The application uses Redis hashes and lists for data storage. All keys have automatic expiration (TTL) to enforce self-destruction.
| Key Pattern | Type | Description | TTL |
|---|---|---|---|
meta:{roomId} |
Hash | Room metadata (connected users, creation time) | 600s |
messages:{roomId} |
List | Message history for the room | Synced with room TTL |
history:{roomId} |
List | Additional history storage | Synced with room TTL |
{roomId} |
- | Reserved for realtime channel | Synced with room TTL |
{
connected: string[]; // Array of auth tokens (max 2 users)
createdAt: number; // Unix timestamp in milliseconds
}{
id: string; // nanoid-generated
sender: string; // Username of message sender
text: string; // Message content (max 1000 chars)
timestamp: number; // Unix timestamp in milliseconds
roomId: string; // Reference to chat room
token?: string; // Auth token (only visible to sender)
}// src/lib/redis.ts
import { Redis } from "@upstash/redis";
export const redis = Redis.fromEnv();The client automatically reads from environment variables:
UPSTASH_REDIS_REST_URLUPSTASH_REDIS_REST_TOKEN
When a room is created or messages are sent, TTL is synchronized across all related keys:
// Room creation (src/app/api/[[...slugs]]/route.ts)
const ROOM_TTL_SECONDS = 60 * 10; // 10 minutes
await redis.expire(`meta:${roomId}`, ROOM_TTL_SECONDS);
// After sending a message, sync TTL to message keys
const remaining = await redis.ttl(`meta:${roomId}`);
await Promise.all([
redis.expire(`messages:${roomId}`, remaining),
redis.expire(`history:${roomId}`, remaining),
redis.expire(roomId, remaining),
]);The middleware handles room access control and user authentication:
// src/proxy.ts
export const proxy = async (req: NextRequest) => {
// 1. Validate room path
// 2. Check if room exists in Redis
// 3. Validate existing token or issue new one
// 4. Enforce 2-user limit
// 5. Update Redis connected users list
};
export const config = {
matcher: "/room/:path*",
};- Cookie Token: Users receive an
x-auth-tokencookie on first room access - Token Validation: Middleware checks if token exists in room's
connectedarray - Room Capacity: Maximum 2 users per room enforced at middleware level
- Token Persistence: Cookies are HTTP-only and secure in production
| Error Code | Trigger | Redirect |
|---|---|---|
room-not-found |
Room doesn't exist or expired | /?error=room-not-found |
room-full |
Room has 2 users already | /?error=room-full |
All API routes are prefixed with /api and handled by Elysia.
POST /api/room/createResponse:
{ "roomId": "abc123" }Side Effects:
- Creates
meta:{roomId}hash in Redis - Sets 10-minute TTL
GET /api/room/ttl?roomId={roomId}Query Parameters:
| Parameter | Type | Required |
|---|---|---|
roomId |
string | Yes |
Response:
{ "ttl": 480 }DELETE /api/room/?roomId={roomId}Query Parameters:
| Parameter | Type | Required |
|---|---|---|
roomId |
string | Yes |
Side Effects:
- Emits
chat.destroyevent via Upstash Realtime - Deletes
meta:{roomId},messages:{roomId},history:{roomId}
POST /api/messages/?roomId={roomId}Query Parameters:
| Parameter | Type | Required |
|---|---|---|
roomId |
string | Yes |
Body:
{
"sender": "anonymous-wolf-x7k9m",
"text": "Hello!"
}Validation:
sender: max 100 characterstext: max 1000 characters
Side Effects:
- Stores message in
messages:{roomId}list - Emits
chat.messageevent via Upstash Realtime - Synchronizes TTL across all room keys
GET /api/messages/?roomId={roomId}Query Parameters:
| Parameter | Type | Required |
|---|---|---|
roomId |
string | Yes |
Response:
{
"messages": [
{
"id": "msg123",
"sender": "anonymous-wolf-x7k9m",
"text": "Hello!",
"timestamp": 1744567890123,
"roomId": "abc123",
"token": "tok_abc" // Only present if sent by current user
}
]
}const schema = {
chat: {
message: z.object({
id: z.string(),
sender: z.string(),
text: z.string(),
timestamp: z.number(),
roomId: z.string(),
token: z.string().optional(),
}),
destroy: z.object({
isDestoyed: z.literal(true),
}),
},
};| Event | Channel | Payload | Description |
|---|---|---|---|
chat.message |
{roomId} |
Message |
New message received |
chat.destroy |
{roomId} |
{ isDestoyed: true } |
Room self-destructed |
// src/app/room/[roomId]/page.tsx
useRealtime({
channels: [roomId],
events: ["chat.message", "chat.destroy"],
onData: ({ event }) => {
if (event === "chat.message") {
refetch(); // Refresh messages from cache
}
if (event === "chat.destroy") {
router.push("/?destroyed=true");
}
},
});Manages anonymous username generation and persistence.
Behavior:
- Checks
localStoragefor existing username - If not found, generates new username using pattern:
anonymous-{animal}-{nanoid(5)} - Persists username to
localStoragefor future sessions
Usage:
const { username } = useUsername();Return Value:
{
username: string; // e.g., "anonymous-wolf-x7k9m"
}Storage Key: chat_username (defined in src/constants/index.ts)
Create a .env file in the project root:
# Upstash Redis (REST API)
UPSTASH_REDIS_REST_URL="https://your-app.upstash.io"
UPSTASH_REDIS_REST_TOKEN="your-token"
# Upstash Realtime (optional - defaults from REST)
UPSTASH_REALTIME_URL="wss://your-app.upstash.io"
UPSTASH_REALTIME_TOKEN="your-realtime-token"- Create a database at upstash.com
- Copy the REST URL and token from the database dashboard
- For Realtime, enable the Realtime feature in your database settings
- Bun (recommended) or Node.js 18+
- Upstash Redis database with Realtime enabled
# Clone the repository
git clone <repository-url>
cd realtime-chat
# Install dependencies
bun install
# Set up environment variables
cp .env.example .env
# Edit .env with your Upstash credentials# Start development server
bun dev
# Open http://localhost:3000# Build for production
bun build
# Start production server
bun startbun lint┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Room │────▶│ Active │────▶│ Destroyed │
│ Created │ │ Chatting │ │ (Auto/Man) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ │ │
▼ ▼ ▼
meta:{id} messages:{id} All keys
connected: [] TTL synced deleted
TTL: 600s
- Triggered when TTL expires (10 minutes from creation)
- Redis automatically removes expired keys
- Realtime emits
chat.destroyto connected clients
- User clicks "DESTROY NOW" button
- Emits
chat.destroyevent immediately - Deletes all room-related keys
- Redirects all users to lobby with
destroyed=true
- Token-Based Auth: HTTP-only cookies prevent XSS token theft
- Room Isolation: Each room has unique ID, no cross-room data access
- Capacity Limits: 2-user maximum prevents room overcrowding
- Automatic Expiry: All data self-destructs, no permanent storage
- Secure Cookies:
Secureflag enabled in production
MIT
