Building a Real AI Application
In this tutorial, we'll build a complete, production-ready AI chat application from scratch. You'll learn how to create a full-stack web application with streaming responses, support for multiple AI providers, and real-time cost tracking—all the features you'd find in a professional AI product.
What You'll Build
A modern AI chat application with:
- Express.js backend with RESTful API endpoints
- Server-Sent Events (SSE) for real-time streaming responses
- Multi-provider support - Switch between OpenAI and Claude
- Real-time cost tracking - Monitor API usage and expenses
- Responsive frontend - Clean, professional user interface
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed
- API keys for OpenAI and/or Anthropic
- Basic knowledge of JavaScript and HTTP
- A text editor (VS Code recommended)
Project Structure
Let's create a well-organized project:
ai-chat-app/
├── server.js # Express server with SSE
├── package.json # Dependencies and scripts
├── .env # API keys (DO NOT commit)
├── .env.example # Template for environment variables
└── public/
├── index.html # Frontend UI
├── style.css # Styling
└── app.js # Client-side logic
Step 1: Project Setup
Create your project directory and initialize it:
mkdir ai-chat-app
cd ai-chat-app
npm init -y
Install the required dependencies:
npm install express dotenv cors openai @anthropic-ai/sdk
We're using express for the web server, dotenv for environment variables, cors for cross-origin requests, and the official SDKs for OpenAI and Anthropic.
Step 2: Configuration Files
package.json
Create or update your
package.json{
"name": "ai-chat-app",
"version": "1.0.0",
"description": "Production-ready AI chat application with streaming",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"keywords": ["ai", "chat", "streaming", "openai", "claude"],
"author": "",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.27.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"openai": "^4.56.0"
}
}
The
"type": "module"--watch.env.example
Create a template for environment variables:
# OpenAI Configuration
OPENAI_API_KEY=your_openai_api_key_here
# Anthropic Configuration
ANTHROPIC_API_KEY=your_anthropic_api_key_here
# Server Configuration
PORT=3000
NODE_ENV=development
.env
Create your actual
.envOPENAI_API_KEY=sk-proj-...your-actual-key...
ANTHROPIC_API_KEY=sk-ant-...your-actual-key...
PORT=3000
NODE_ENV=development
NEVER commit your .env
.gitignore.env.exampleStep 3: Backend Server
Create
server.jsimport express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
// Initialize AI clients
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
<Callout type="info">
**Streaming Definition:** The process of sending data continuously in small chunks as it becomes available, rather than waiting for the complete response. For AI, streaming allows users to see text appear word-by-word in real-time, improving perceived performance and user experience.
</Callout>
// Pricing per 1M tokens (as of 2024)
const PRICING = {
'gpt-4': { input: 30.00, output: 60.00 },
'gpt-4-turbo': { input: 10.00, output: 30.00 },
'gpt-3.5-turbo': { input: 0.50, output: 1.50 },
'claude-3-5-sonnet-20241022': { input: 3.00, output: 15.00 },
'claude-3-opus-20240229': { input: 15.00, output: 75.00 },
'claude-3-haiku-20240307': { input: 0.25, output: 1.25 },
};
// Calculate cost based on tokens
function calculateCost(model, inputTokens, outputTokens) {
const pricing = PRICING[model];
if (!pricing) return 0;
const inputCost = (inputTokens / 1_000_000) * pricing.input;
const outputCost = (outputTokens / 1_000_000) * pricing.output;
return {
inputCost: inputCost.toFixed(6),
outputCost: outputCost.toFixed(6),
totalCost: (inputCost + outputCost).toFixed(6),
};
}
// Health check endpoint
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
providers: {
openai: !!process.env.OPENAI_API_KEY,
anthropic: !!process.env.ANTHROPIC_API_KEY,
}
});
});
// Get available models
app.get('/api/models', (req, res) => {
res.json({
openai: ['gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: [
'claude-3-5-sonnet-20241022',
'claude-3-opus-20240229',
'claude-3-haiku-20240307'
],
});
});
// OpenAI streaming endpoint
app.post('/api/chat/openai', async (req, res) => {
const { messages, model = 'gpt-3.5-turbo' } = req.body;
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ error: 'Messages array is required' });
}
if (!process.env.OPENAI_API_KEY) {
return res.status(500).json({ error: 'OpenAI API key not configured' });
}
// Set headers for Server-Sent Events
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
const stream = await openai.chat.completions.create({
model,
messages,
stream: true,
stream_options: { include_usage: true },
});
let fullResponse = '';
let inputTokens = 0;
let outputTokens = 0;
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content || '';
if (delta) {
fullResponse += delta;
// Send content chunk
res.write(`data: ${JSON.stringify({
type: 'content',
content: delta
})}\n\n`);
}
// Check for usage information
if (chunk.usage) {
inputTokens = chunk.usage.prompt_tokens;
outputTokens = chunk.usage.completion_tokens;
}
}
// Calculate and send cost information
const cost = calculateCost(model, inputTokens, outputTokens);
res.write(`data: ${JSON.stringify({
type: 'usage',
model,
inputTokens,
outputTokens,
totalTokens: inputTokens + outputTokens,
cost,
})}\n\n`);
// Send done signal
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
res.end();
} catch (error) {
console.error('OpenAI Error:', error);
res.write(`data: ${JSON.stringify({
type: 'error',
error: error.message
})}\n\n`);
res.end();
}
});
// Anthropic streaming endpoint
app.post('/api/chat/anthropic', async (req, res) => {
const { messages, model = 'claude-3-haiku-20240307' } = req.body;
if (!messages || !Array.isArray(messages)) {
return res.status(400).json({ error: 'Messages array is required' });
}
if (!process.env.ANTHROPIC_API_KEY) {
return res.status(500).json({ error: 'Anthropic API key not configured' });
}
// Set headers for Server-Sent Events
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// Separate system message from other messages
const systemMessage = messages.find(m => m.role === 'system');
const conversationMessages = messages
.filter(m => m.role !== 'system')
.map(m => ({
role: m.role === 'assistant' ? 'assistant' : 'user',
content: m.content,
}));
const stream = await anthropic.messages.stream({
model,
max_tokens: 4096,
system: systemMessage?.content || undefined,
messages: conversationMessages,
});
let fullResponse = '';
// Handle text deltas
stream.on('text', (text) => {
fullResponse += text;
res.write(`data: ${JSON.stringify({
type: 'content',
content: text
})}\n\n`);
});
// Handle completion
const finalMessage = await stream.finalMessage();
const inputTokens = finalMessage.usage.input_tokens;
const outputTokens = finalMessage.usage.output_tokens;
const cost = calculateCost(model, inputTokens, outputTokens);
res.write(`data: ${JSON.stringify({
type: 'usage',
model,
inputTokens,
outputTokens,
totalTokens: inputTokens + outputTokens,
cost,
})}\n\n`);
res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
res.end();
} catch (error) {
console.error('Anthropic Error:', error);
res.write(`data: ${JSON.stringify({
type: 'error',
error: error.message
})}\n\n`);
res.end();
}
});
// Unified chat endpoint that routes to the appropriate provider
app.post('/api/chat', async (req, res) => {
const { provider, ...restBody } = req.body;
if (provider === 'openai') {
req.body = restBody;
return app.handle(
Object.assign(req, { method: 'POST', url: '/api/chat/openai' }),
res
);
} else if (provider === 'anthropic') {
req.body = restBody;
return app.handle(
Object.assign(req, { method: 'POST', url: '/api/chat/anthropic' }),
res
);
} else {
return res.status(400).json({
error: 'Invalid provider. Use "openai" or "anthropic"'
});
}
});
// Start server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV}`);
console.log(`OpenAI configured: ${!!process.env.OPENAI_API_KEY}`);
console.log(`Anthropic configured: ${!!process.env.ANTHROPIC_API_KEY}`);
});
Understanding Server-Sent Events (SSE)
Server-Sent Events (SSE) Definition: A web technology that enables servers to push real-time updates to web browsers over a single HTTP connection. Unlike WebSockets which are bidirectional, SSE is one-way (server to client), making it simpler and ideal for streaming AI responses as they're generated.
What is SSE?
Server-Sent Events (SSE) is a standard that allows servers to push data to clients over HTTP. Unlike WebSockets, SSE is unidirectional (server to client only) and works over regular HTTP, making it perfect for streaming AI responses.
How SSE Works
- Client initiates connection - Makes a request to the server
- Server keeps connection open - Instead of closing, keeps streaming
- Server sends data chunks - Writes data as it becomes available
- Client receives updates - Processes each chunk in real-time
- Connection closes - When streaming is complete
Key SSE Headers
res.setHeader('Content-Type', 'text/event-stream'); // Tells browser to expect a stream
res.setHeader('Cache-Control', 'no-cache'); // Prevents caching
res.setHeader('Connection', 'keep-alive'); // Keeps connection open
SSE Data Format
Each SSE message follows this format:
data: {"type": "content", "content": "Hello"}\n\n
Key points:
- Lines start with
data: - Followed by the message (usually JSON)
- End with two newline characters ()
\n\n
SSE vs WebSockets:
- SSE: Unidirectional (server to client), uses HTTP, automatic reconnection, simpler
- WebSockets: Bidirectional, custom protocol, manual reconnection, more complex
For AI streaming, SSE is ideal because you only need server-to-client communication!
Step 4: Frontend HTML
Create
public/index.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Chat Application</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>AI Chat Application</h1>
<p class="subtitle">Multi-provider chat with streaming and cost tracking</p>
</header>
<div class="controls">
<div class="control-group">
<label for="provider">Provider:</label>
<select id="provider">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
</select>
</div>
<div class="control-group">
<label for="model">Model:</label>
<select id="model">
<option value="gpt-3.5-turbo">GPT-3.5 Turbo</option>
<option value="gpt-4-turbo">GPT-4 Turbo</option>
<option value="gpt-4">GPT-4</option>
</select>
</div>
<button id="clearBtn" class="btn-secondary">Clear Chat</button>
</div>
<div class="stats">
<div class="stat-item">
<span class="stat-label">Input Tokens:</span>
<span id="inputTokens" class="stat-value">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Output Tokens:</span>
<span id="outputTokens" class="stat-value">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Total Cost:</span>
<span id="totalCost" class="stat-value">$0.000000</span>
</div>
</div>
<div id="chatContainer" class="chat-container">
<div class="welcome-message">
<h2>Welcome to AI Chat!</h2>
<p>Select a provider and model, then start chatting.</p>
<ul>
<li>Real-time streaming responses</li>
<li>Token and cost tracking</li>
<li>Support for OpenAI and Claude</li>
</ul>
</div>
</div>
<div class="input-container">
<textarea
id="messageInput"
placeholder="Type your message... (Shift+Enter for new line, Enter to send)"
rows="3"
></textarea>
<button id="sendBtn" class="btn-primary">Send</button>
</div>
<div id="status" class="status"></div>
</div>
<script src="app.js"></script>
</body>
</html>
Step 5: Frontend Styling
Create
public/style.css* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #4f46e5;
--primary-hover: #4338ca;
--secondary-color: #6b7280;
--background: #f9fafb;
--surface: #ffffff;
--text-primary: #111827;
--text-secondary: #6b7280;
--border: #e5e7eb;
--success: #10b981;
--error: #ef4444;
--user-message: #eff6ff;
--assistant-message: #f3f4f6;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
background: var(--background);
color: var(--text-primary);
line-height: 1.6;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid var(--border);
}
header h1 {
color: var(--primary-color);
font-size: 2rem;
margin-bottom: 8px;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
}
.controls {
display: flex;
gap: 15px;
margin-bottom: 20px;
flex-wrap: wrap;
align-items: flex-end;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.control-group label {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
}
select {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.95rem;
cursor: pointer;
transition: border-color 0.2s;
}
select:hover {
border-color: var(--primary-color);
}
select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.stats {
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 15px;
background: var(--surface);
border-radius: 8px;
box-shadow: var(--shadow);
flex-wrap: wrap;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 500;
}
.stat-value {
font-size: 1.1rem;
font-weight: 600;
color: var(--primary-color);
}
.chat-container {
flex: 1;
background: var(--surface);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
overflow-y: auto;
max-height: 500px;
box-shadow: var(--shadow);
}
.welcome-message {
text-align: center;
color: var(--text-secondary);
padding: 40px 20px;
}
.welcome-message h2 {
color: var(--text-primary);
margin-bottom: 15px;
}
.welcome-message ul {
list-style: none;
margin-top: 20px;
}
.welcome-message li {
padding: 8px 0;
}
.welcome-message li:before {
content: "✓ ";
color: var(--success);
font-weight: bold;
margin-right: 8px;
}
.message {
margin-bottom: 20px;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message-header {
font-weight: 600;
margin-bottom: 8px;
font-size: 0.9rem;
}
.user .message-header {
color: var(--primary-color);
}
.assistant .message-header {
color: var(--text-secondary);
}
.message-content {
padding: 12px 16px;
border-radius: 8px;
white-space: pre-wrap;
word-wrap: break-word;
}
.user .message-content {
background: var(--user-message);
border-left: 3px solid var(--primary-color);
}
.assistant .message-content {
background: var(--assistant-message);
border-left: 3px solid var(--text-secondary);
}
.message-meta {
margin-top: 8px;
font-size: 0.8rem;
color: var(--text-secondary);
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.input-container {
display: flex;
gap: 10px;
margin-bottom: 15px;
}
textarea {
flex: 1;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-family: inherit;
font-size: 0.95rem;
resize: vertical;
min-height: 60px;
transition: border-color 0.2s;
}
textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
button {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-size: 0.95rem;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.btn-secondary {
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--background);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status {
text-align: center;
padding: 10px;
border-radius: 6px;
font-size: 0.9rem;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.status.info {
background: #dbeafe;
color: #1e40af;
}
.status.error {
background: #fee2e2;
color: #991b1b;
}
.status.success {
background: #d1fae5;
color: #065f46;
}
.typing-indicator {
display: inline-block;
padding: 8px 12px;
background: var(--assistant-message);
border-radius: 8px;
margin-top: 8px;
}
.typing-indicator span {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-secondary);
margin: 0 2px;
animation: bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) {
animation-delay: -0.32s;
}
.typing-indicator span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
header h1 {
font-size: 1.5rem;
}
.controls {
flex-direction: column;
align-items: stretch;
}
.stats {
flex-direction: column;
gap: 10px;
}
.input-container {
flex-direction: column;
}
}
Step 6: Frontend JavaScript
Create
public/app.jsclass ChatApp {
constructor() {
this.messages = [];
this.totalInputTokens = 0;
this.totalOutputTokens = 0;
this.totalCostAmount = 0;
this.initializeElements();
this.attachEventListeners();
this.loadModels();
}
initializeElements() {
this.chatContainer = document.getElementById('chatContainer');
this.messageInput = document.getElementById('messageInput');
this.sendBtn = document.getElementById('sendBtn');
this.clearBtn = document.getElementById('clearBtn');
this.providerSelect = document.getElementById('provider');
this.modelSelect = document.getElementById('model');
this.status = document.getElementById('status');
this.inputTokensEl = document.getElementById('inputTokens');
this.outputTokensEl = document.getElementById('outputTokens');
this.totalCostEl = document.getElementById('totalCost');
}
attachEventListeners() {
this.sendBtn.addEventListener('click', () => this.sendMessage());
this.clearBtn.addEventListener('click', () => this.clearChat());
this.providerSelect.addEventListener('change', () => this.updateModels());
this.messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
}
async loadModels() {
try {
const response = await fetch('/api/models');
const models = await response.json();
this.models = models;
this.updateModels();
} catch (error) {
console.error('Failed to load models:', error);
}
}
updateModels() {
const provider = this.providerSelect.value;
const models = this.models[provider] || [];
this.modelSelect.innerHTML = '';
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
this.modelSelect.appendChild(option);
});
}
setStatus(message, type = 'info') {
this.status.textContent = message;
this.status.className = `status ${type}`;
if (type === 'success') {
setTimeout(() => {
this.status.textContent = '';
this.status.className = 'status';
}, 3000);
}
}
clearChat() {
this.messages = [];
this.chatContainer.innerHTML = `
<div class="welcome-message">
<h2>Chat Cleared!</h2>
<p>Start a new conversation below.</p>
</div>
`;
this.setStatus('Chat cleared', 'success');
}
addMessage(role, content, metadata = {}) {
const message = { role, content, metadata, timestamp: new Date() };
this.messages.push(message);
this.renderMessage(message);
this.scrollToBottom();
}
renderMessage(message) {
// Remove welcome message if it exists
const welcome = this.chatContainer.querySelector('.welcome-message');
if (welcome) {
welcome.remove();
}
const messageDiv = document.createElement('div');
messageDiv.className = `message ${message.role}`;
const headerDiv = document.createElement('div');
headerDiv.className = 'message-header';
headerDiv.textContent = message.role === 'user' ? 'You' : 'Assistant';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.textContent = message.content;
messageDiv.appendChild(headerDiv);
messageDiv.appendChild(contentDiv);
// Add metadata if available
if (message.metadata && Object.keys(message.metadata).length > 0) {
const metaDiv = document.createElement('div');
metaDiv.className = 'message-meta';
const { model, inputTokens, outputTokens, cost } = message.metadata;
if (model) {
metaDiv.innerHTML += `<span>Model: ${model}</span>`;
}
if (inputTokens !== undefined) {
metaDiv.innerHTML += `<span>Input: ${inputTokens} tokens</span>`;
}
if (outputTokens !== undefined) {
metaDiv.innerHTML += `<span>Output: ${outputTokens} tokens</span>`;
}
if (cost) {
metaDiv.innerHTML += `<span>Cost: $${cost.totalCost}</span>`;
}
messageDiv.appendChild(metaDiv);
}
this.chatContainer.appendChild(messageDiv);
return messageDiv;
}
updateMessage(messageDiv, content) {
const contentDiv = messageDiv.querySelector('.message-content');
contentDiv.textContent = content;
}
addMetadata(messageDiv, metadata) {
let metaDiv = messageDiv.querySelector('.message-meta');
if (!metaDiv) {
metaDiv = document.createElement('div');
metaDiv.className = 'message-meta';
messageDiv.appendChild(metaDiv);
}
const { model, inputTokens, outputTokens, cost } = metadata;
metaDiv.innerHTML = '';
if (model) {
metaDiv.innerHTML += `<span>Model: ${model}</span>`;
}
if (inputTokens !== undefined) {
metaDiv.innerHTML += `<span>Input: ${inputTokens} tokens</span>`;
}
if (outputTokens !== undefined) {
metaDiv.innerHTML += `<span>Output: ${outputTokens} tokens</span>`;
}
if (cost) {
metaDiv.innerHTML += `<span>Cost: $${cost.totalCost}</span>`;
}
}
scrollToBottom() {
this.chatContainer.scrollTop = this.chatContainer.scrollHeight;
}
showTypingIndicator() {
const indicator = document.createElement('div');
indicator.id = 'typingIndicator';
indicator.className = 'typing-indicator';
indicator.innerHTML = '<span></span><span></span><span></span>';
this.chatContainer.appendChild(indicator);
this.scrollToBottom();
return indicator;
}
removeTypingIndicator() {
const indicator = document.getElementById('typingIndicator');
if (indicator) {
indicator.remove();
}
}
async sendMessage() {
const content = this.messageInput.value.trim();
if (!content) return;
const provider = this.providerSelect.value;
const model = this.modelSelect.value;
// Disable input while processing
this.messageInput.disabled = true;
this.sendBtn.disabled = true;
this.messageInput.value = '';
// Add user message
this.addMessage('user', content);
// Show typing indicator
const typingIndicator = this.showTypingIndicator();
// Prepare messages for API
const apiMessages = this.messages.map(m => ({
role: m.role,
content: m.content,
}));
try {
this.setStatus('Streaming response...', 'info');
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider,
model,
messages: apiMessages,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Remove typing indicator
this.removeTypingIndicator();
// Create assistant message div
const assistantMessageDiv = this.renderMessage({
role: 'assistant',
content: '',
metadata: {},
});
let fullContent = '';
let metadata = {};
// Read the stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'content') {
fullContent += data.content;
this.updateMessage(assistantMessageDiv, fullContent);
this.scrollToBottom();
} else if (data.type === 'usage') {
metadata = data;
// Update cumulative stats
this.totalInputTokens += data.inputTokens;
this.totalOutputTokens += data.outputTokens;
this.totalCostAmount += parseFloat(data.cost.totalCost);
this.inputTokensEl.textContent = this.totalInputTokens.toLocaleString();
this.outputTokensEl.textContent = this.totalOutputTokens.toLocaleString();
this.totalCostEl.textContent = `$${this.totalCostAmount.toFixed(6)}`;
this.addMetadata(assistantMessageDiv, data);
} else if (data.type === 'done') {
this.setStatus('Response complete', 'success');
} else if (data.type === 'error') {
throw new Error(data.error);
}
} catch (e) {
console.error('Error parsing SSE data:', e);
}
}
}
}
// Update the messages array with the complete assistant message
this.messages.push({
role: 'assistant',
content: fullContent,
metadata,
timestamp: new Date(),
});
} catch (error) {
console.error('Error:', error);
this.setStatus(`Error: ${error.message}`, 'error');
this.removeTypingIndicator();
} finally {
// Re-enable input
this.messageInput.disabled = false;
this.sendBtn.disabled = false;
this.messageInput.focus();
}
}
}
// Initialize the app when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new ChatApp();
});
Step 7: Running the Application
Install dependencies and start the server:
# Install dependencies
npm install
# Start the server
npm start
# OR use watch mode for development (auto-restart on changes)
npm run dev
Open your browser to
http://localhost:3000The
--watchHow the Application Works
Request Flow
- User sends message - Frontend captures input
- POST to /api/chat - Sends messages and provider info
- Server routes request - Directs to OpenAI or Anthropic endpoint
- Streaming begins - Server sets SSE headers
- Tokens arrive - Each token is sent as it's generated
- Client updates UI - Real-time display of response
- Stream completes - Usage stats and cost calculated
Data Flow Diagram
User Input
|
v
Frontend (app.js)
|
v
POST /api/chat
|
v
Server (server.js)
|
+-- provider=openai --> OpenAI API
| |
| v
| Stream tokens
| |
+-- provider=anthropic --> Anthropic API
|
v
Stream tokens
|
v
Calculate cost
|
v
SSE to client
|
v
Update UI in real-time
Production Enhancements
Middleware Definition: Functions that execute during the request-response cycle in web applications, sitting between the incoming request and your route handlers. Middleware can modify requests, perform authentication, log activity, or handle errors before passing control to the next function.
1. Authentication & Authorization
Add user authentication to secure your API:
import jwt from 'jsonwebtoken';
// Middleware to verify JWT tokens
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid token' });
}
req.user = user;
next();
});
};
// Apply to protected routes
app.post('/api/chat', authenticateToken, async (req, res) => {
// Your chat logic here
// Now you have access to req.user
});
2. Rate Limiting
Prevent abuse and control costs with rate limiting:
import rateLimit from 'express-rate-limit';
const chatLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50, // Limit each user to 50 requests per windowMs
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
});
app.post('/api/chat', chatLimiter, async (req, res) => {
// Your chat logic here
});
3. Database Integration
Store conversations for history and analytics:
import { MongoClient } from 'mongodb';
const client = new MongoClient(process.env.MONGODB_URI);
await client.connect();
const db = client.db('ai-chat');
// Save conversation
async function saveConversation(userId, messages, metadata) {
await db.collection('conversations').insertOne({
userId,
messages,
metadata,
createdAt: new Date(),
});
}
// Retrieve user conversations
async function getUserConversations(userId) {
return await db.collection('conversations')
.find({ userId })
.sort({ createdAt: -1 })
.limit(10)
.toArray();
}
4. Error Handling & Logging
Implement comprehensive error handling:
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
// Error handling middleware
app.use((err, req, res, next) => {
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
});
});
CORS Definition: Cross-Origin Resource Sharing (CORS) is a security feature that controls which websites can access your API from a browser. It prevents unauthorized sites from making requests to your server while allowing legitimate requests from approved domains.
5. Caching
Implement caching for repeated queries:
import NodeCache from 'node-cache';
import crypto from 'crypto';
const cache = new NodeCache({ stdTTL: 3600 }); // 1 hour
function getCacheKey(messages, model) {
return crypto
.createHash('md5')
.update(JSON.stringify({ messages, model }))
.digest('hex');
}
// Check cache before making API call
const cacheKey = getCacheKey(messages, model);
const cached = cache.get(cacheKey);
if (cached) {
return res.json({ response: cached, fromCache: true });
}
// After getting response, cache it
cache.set(cacheKey, response);
6. Environment-Based Configuration
Use different configs for development and production:
const config = {
development: {
port: 3000,
logLevel: 'debug',
corsOrigin: '*',
},
production: {
port: process.env.PORT || 8080,
logLevel: 'error',
corsOrigin: process.env.ALLOWED_ORIGINS.split(','),
},
};
const env = process.env.NODE_ENV || 'development';
const currentConfig = config[env];
app.use(cors({
origin: currentConfig.corsOrigin,
}));
Security Best Practices:
- Never expose API keys in client-side code
- Use HTTPS in production
- Implement proper CORS policies
- Validate and sanitize all user inputs
- Set appropriate rate limits
- Use environment variables for sensitive data
- Implement proper session management
- Add request size limits to prevent DoS attacks
Deployment Guide
Deployment Checklist
Before deploying to production:
- Set
NODE_ENV=production - Use environment variables for all secrets
- Enable HTTPS/SSL
- Configure CORS properly
- Add rate limiting
- Implement authentication
- Set up logging and monitoring
- Add error tracking (e.g., Sentry)
- Configure database backups
- Set up CI/CD pipeline
- Add health check endpoints
- Implement graceful shutdown
- Configure reverse proxy (nginx/Apache)
- Set up domain and DNS
- Add analytics and monitoring
Deployment Options
Free/Low-Cost Options:
- Render.com - Free tier, easy deployment
- Railway.app - $5/month, great developer experience
- Vercel - Free for hobby projects
- Fly.io - Free tier available
- Heroku - Free tier (with limitations)
Example: Deploy to Render
- Create a file:
render.yaml
services:
- type: web
name: ai-chat-app
env: node
buildCommand: npm install
startCommand: npm start
envVars:
- key: NODE_ENV
value: production
- key: OPENAI_API_KEY
sync: false
- key: ANTHROPIC_API_KEY
sync: false
- Push to GitHub
- Connect repository to Render
- Add environment variables
- Deploy!
Common Issues & Solutions
Issue: CORS errors
Solution: Configure CORS properly in your server:
app.use(cors({
origin: process.env.ALLOWED_ORIGINS.split(','),
credentials: true,
}));
Issue: SSE connection drops
Solution: Implement automatic reconnection on the client:
async function sendMessageWithRetry(data, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await sendMessage(data);
} catch (error) {
if (i === maxRetries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
}
Issue: High API costs
Solution: Implement caching and use cheaper models for simple queries:
function selectModel(query) {
const wordCount = query.split(' ').length;
if (wordCount < 50) {
return 'gpt-3.5-turbo'; // Cheaper for simple queries
} else {
return 'gpt-4'; // Better for complex queries
}
}
Issue: Slow initial load
Solution: Implement lazy loading and code splitting:
// Load heavy dependencies only when needed
const loadOpenAI = async () => {
const { default: OpenAI } = await import('openai');
return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
};
Next Steps
Now that you've built a production-ready AI application, consider adding:
- Conversation history - Store and retrieve past conversations
- User accounts - Allow users to save their chat history
- File upload - Support image analysis with vision models
- Custom prompts - Build a prompt library for different use cases
- Analytics dashboard - Track usage patterns and costs
- A/B testing - Compare different models and prompts
- Mobile app - Create React Native or Flutter frontend
- Voice input - Integrate speech-to-text APIs
- Export functionality - Allow users to export conversations
- Sharing features - Let users share interesting conversations
Summary
Congratulations! You've built a complete, production-ready AI chat application with:
- Express.js backend with proper API structure
- Server-Sent Events for real-time streaming
- Multi-provider support for OpenAI and Anthropic
- Real-time cost tracking to monitor expenses
- Modern, responsive frontend with excellent UX
- Production-ready features including error handling and logging
The application demonstrates key concepts:
- HTTP streaming with SSE
- API integration with multiple providers
- Real-time data updates
- Cost tracking and optimization
- Production deployment considerations
This application serves as a foundation for building more complex AI-powered applications. You can extend it with features like conversation memory, RAG (Retrieval-Augmented Generation), multi-modal support, and more!
Quiz
Test your understanding of building real AI applications: