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:
AIX_API_KEY=tam_your_key_here
# Optional: any model ID from https://aix.theaimart.co/models
AIX_MODEL=deepseek-ai/DeepSeek-V3.2Variables 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.
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.
'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:
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_MODELto any ID listed on the AIx models page, for exampleanthropic/claude-sonnet-4-6. - Another provider: change
baseURLand 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
- Publish the finished app: list it on the Theaimart AI App Store as a web app.
- Earn from it: ADX SDKs add advertising to web and mobile apps.
- Official references: the Next.js route handler documentation and the OpenAI Node SDK.
