Back
beginner

Building a Real AI Application

Build a production-ready web app with streaming responses, multiple AI providers, and cost tracking

45 min read· Full-Stack· Express· Streaming· Production

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:

bash
mkdir ai-chat-app
cd ai-chat-app
npm init -y

Install the required dependencies:

bash
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
with the following configuration:

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"
field enables ES6 module syntax (import/export) instead of CommonJS (require). The
--watch
flag automatically restarts the server when files change.

.env.example

Create a template for environment variables:

bash
# 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

.env
file with real API keys:

bash
OPENAI_API_KEY=sk-proj-...your-actual-key...
ANTHROPIC_API_KEY=sk-ant-...your-actual-key...
PORT=3000
NODE_ENV=development

NEVER commit your

.env
file to version control! Always add it to
.gitignore
. Use
.env.example
as a template for other developers.

Step 3: Backend Server

Create

server.js
with the complete backend implementation:

javascript
import 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

  1. Client initiates connection - Makes a request to the server
  2. Server keeps connection open - Instead of closing, keeps streaming
  3. Server sends data chunks - Writes data as it becomes available
  4. Client receives updates - Processes each chunk in real-time
  5. Connection closes - When streaming is complete

Key SSE Headers

javascript
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
with the user interface:

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"&gt;0</span>
      </div>
      <div class="stat-item">
        <span class="stat-label">Output Tokens:</span>
        <span id="outputTokens" class="stat-value"&gt;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
with modern, responsive styles:

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.js
with the client-side logic:

javascript
class 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:

bash
# 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:3000
and start chatting!

The

--watch
flag in Node.js 18+ automatically restarts the server when you make changes to files. This is perfect for development!

How the Application Works

Request Flow

  1. User sends message - Frontend captures input
  2. POST to /api/chat - Sends messages and provider info
  3. Server routes request - Directs to OpenAI or Anthropic endpoint
  4. Streaming begins - Server sets SSE headers
  5. Tokens arrive - Each token is sent as it's generated
  6. Client updates UI - Real-time display of response
  7. 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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

  1. Never expose API keys in client-side code
  2. Use HTTPS in production
  3. Implement proper CORS policies
  4. Validate and sanitize all user inputs
  5. Set appropriate rate limits
  6. Use environment variables for sensitive data
  7. Implement proper session management
  8. 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

  1. Create a
    render.yaml
    file:
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
  1. Push to GitHub
  2. Connect repository to Render
  3. Add environment variables
  4. Deploy!

Common Issues & Solutions

Issue: CORS errors

Solution: Configure CORS properly in your server:

javascript
app.use(cors({
  origin: process.env.ALLOWED_ORIGINS.split(','),
  credentials: true,
}));

Issue: SSE connection drops

Solution: Implement automatic reconnection on the client:

javascript
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:

javascript
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:

javascript
// 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:

  1. Conversation history - Store and retrieve past conversations
  2. User accounts - Allow users to save their chat history
  3. File upload - Support image analysis with vision models
  4. Custom prompts - Build a prompt library for different use cases
  5. Analytics dashboard - Track usage patterns and costs
  6. A/B testing - Compare different models and prompts
  7. Mobile app - Create React Native or Flutter frontend
  8. Voice input - Integrate speech-to-text APIs
  9. Export functionality - Allow users to export conversations
  10. 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: