Skip to content

AIx

Build a Streaming AI Chat App with Next.js and an OpenAI-Compatible API

A working Next.js App Router chat app that streams model output from an OpenAI-compatible API, with the API key kept on the server. Code checked against openai 7 types.

Illustration of a black humanoid robot typing on a laptop
On this page

This guide builds a small chat app with the Next.js App Router. The browser sends the conversation to a route handler on your server; the route handler calls a model through an OpenAI-compatible API and streams the reply back as it is generated. The API key never reaches the browser.

The example uses AIx, Theaimart's OpenAI-compatible model API, but the same code works with any provider that implements the OpenAI chat completions format: change the base URL, the key and the model name.

What you need

  • Node.js 20 or later and a Next.js App Router project (npx create-next-app@latest).
  • An API key. For AIx, create one in the AIx dashboard; keys start with tam_.
  • The official OpenAI SDK: npm install openai. The code below was type-checked against version 7.

Put the key in .env.local, which Next.js loads on the server and git should ignore:

.env.local
AIX_API_KEY=tam_your_key_here
# Optional: any model ID from https://aix.theaimart.co/models
AIX_MODEL=deepseek-ai/DeepSeek-V3.2

Variables without the NEXT_PUBLIC_ prefix are only available to server code, which is exactly what an API key needs.

1. A streaming route handler

Create app/api/chat/route.ts. It validates the request, asks for a streamed completion, and turns the SDK's async iterator of chunks into a plain text stream the browser can read.

app/api/chat/route.ts
import OpenAI from 'openai';
 
// Server-only: the key never reaches the browser.
const client = new OpenAI({
  baseURL: 'https://api.aix.theaimart.co/v1',
  apiKey: process.env.AIX_API_KEY,
});
 
type ChatMessage = { role: 'user' | 'assistant'; content: string };
 
export async function POST(request: Request) {
  const { messages } = (await request.json()) as { messages: ChatMessage[] };
 
  if (!Array.isArray(messages) || messages.length === 0 || messages.length > 50) {
    return Response.json({ error: 'Send between 1 and 50 messages.' }, { status: 400 });
  }
 
  const completion = await client.chat.completions.create({
    model: process.env.AIX_MODEL ?? 'deepseek-ai/DeepSeek-V3.2',
    messages: [{ role: 'system', content: 'You are a concise, helpful assistant.' }, ...messages],
    stream: true,
  });
 
  const encoder = new TextEncoder();
  const body = new ReadableStream<Uint8Array>({
    async start(controller) {
      try {
        for await (const chunk of completion) {
          const text = chunk.choices[0]?.delta?.content;
          if (text) controller.enqueue(encoder.encode(text));
        }
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    },
    cancel() {
      completion.controller.abort();
    },
  });
 
  return new Response(body, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' },
  });
}

Three details matter in production:

  • Validation before spending money. The length check rejects empty or oversized histories before a paid request is made. Add authentication and per-user rate limits before exposing this route publicly.
  • Cancellation. When the visitor closes the tab, the response stream is cancelled; completion.controller.abort() stops the upstream request so you are not billed for tokens nobody reads.
  • Plain text, not SSE. The route forwards only the text deltas. The browser does not need to parse data: lines, and provider-specific fields stay on the server.

2. A client component that reads the stream

Create app/components/Chat.tsx. It posts the history, then reads the response body chunk by chunk and re-renders the assistant message as text arrives.

app/components/Chat.tsx
'use client';
 
import { useState, type FormEvent } from 'react';
 
type ChatMessage = { role: 'user' | 'assistant'; content: string };
 
export default function Chat() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState('');
  const [busy, setBusy] = useState(false);
 
  async function send(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const text = input.trim();
    if (!text || busy) return;
 
    const history: ChatMessage[] = [...messages, { role: 'user', content: text }];
    setMessages([...history, { role: 'assistant', content: '' }]);
    setInput('');
    setBusy(true);
 
    try {
      const response = await fetch('/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ messages: history }),
      });
      if (!response.ok || !response.body) throw new Error(`Request failed: ${response.status}`);
 
      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let reply = '';
      for (;;) {
        const { done, value } = await reader.read();
        if (done) break;
        reply += decoder.decode(value, { stream: true });
        setMessages([...history, { role: 'assistant', content: reply }]);
      }
    } catch {
      setMessages([...history, { role: 'assistant', content: 'Something went wrong. Please try again.' }]);
    } finally {
      setBusy(false);
    }
  }
 
  return (
    <div>
      <ul>
        {messages.map((message, index) => (
          <li key={index}>
            <strong>{message.role === 'user' ? 'You' : 'Assistant'}:</strong> {message.content}
          </li>
        ))}
      </ul>
      <form onSubmit={send}>
        <label htmlFor="prompt">Message</label>
        <input id="prompt" value={input} onChange={(e) => setInput(e.target.value)} disabled={busy} />
        <button type="submit" disabled={busy}>Send</button>
      </form>
    </div>
  );
}

TextDecoder is called with stream: true so a multi-byte character split across two chunks is decoded correctly instead of turning into a replacement character.

Render it from a page:

app/page.tsx
import Chat from './components/Chat';
 
export default function Page() {
  return <Chat />;
}

3. Switching models or providers

Because the request shape is the OpenAI chat completions format, changing model is a configuration change:

  • Another AIx model: set AIX_MODEL to any ID listed on the AIx models page, for example anthropic/claude-sonnet-4-6.
  • Another provider: change baseURL and the key. Model IDs are provider-specific, so update the default model too.

AIx documents its streaming behaviour: responses are standard OpenAI server-sent events ending in [DONE], and models that cannot stream token by token still return a valid final chunk, so the client above does not change.

4. Before you ship

  • Never call the model API from the browser. Anything in client code, including a key, is public.
  • Bound every input: message count, message length and total request size.
  • Handle failure visibly. The component shows an error message rather than leaving an empty reply.
  • Log usage, not prompts, unless you have told users you store what they type.
  • Set a spending limit with your provider. On AIx, usage is billed from a prepaid balance, which caps what a runaway loop can cost.

Going further