The gap between 'I want to add AI to my app' and 'I have working AI in my app' is smaller than you think. OpenAI's API is just HTTP - you send a message, you get a response. The real engineering work is in the details: keeping your API key server-side, streaming responses so users don't stare at a blank screen, handling errors gracefully, and making sure one user can't spam your account into a $500 bill.
# The Architecture: Never Expose Your API Key
Rule zero: the OpenAI API key never touches the browser. All requests go through your own backend - a Next.js API route or a dedicated Express endpoint. Your server holds the key, proxies the request, and returns the response. This also lets you add authentication, rate limiting, and logging in one place.
// app/api/chat/route.ts - server-side API route
import OpenAI from "openai";
import { NextRequest } from "next/server";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, // server-only env var, never exposed
});
export async function POST(req: NextRequest) {
const { messages } = await req.json();
// Basic validation
if (!messages || !Array.isArray(messages)) {
return Response.json({ error: "Invalid request" }, { status: 400 });
}
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini", // cheaper for most use cases
messages,
max_tokens: 1000,
temperature: 0.7,
});
return Response.json({
message: completion.choices[0].message.content,
tokens: completion.usage?.total_tokens,
});
}# Streaming: Don't Make Users Wait
GPT-4 can take 5-10 seconds to generate a full response. Without streaming, your users see nothing for that entire time. With streaming, words appear token by token - like watching someone type. It feels dramatically more responsive even though the total time is the same.
// app/api/chat/stream/route.ts - streaming version
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(req: Request) {
const { messages } = await req.json();
const stream = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages,
stream: true, // the magic flag
});
// Return a ReadableStream - browser receives tokens as they generate
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const token = chunk.choices[0]?.delta?.content || "";
controller.enqueue(new TextEncoder().encode(token));
}
controller.close();
},
});
return new Response(readable, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}// Frontend: consuming the stream
"use client";
import { useState } from "react";
export function ChatBox() {
const [response, setResponse] = useState("");
const [loading, setLoading] = useState(false);
async function sendMessage(userMessage: string) {
setLoading(true);
setResponse("");
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: userMessage }] }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
setResponse(prev => prev + decoder.decode(value));
}
setLoading(false);
}
return (
<div>
<div className="response">{response}{loading && <span className="cursor">▌</span>}</div>
</div>
);
}# Prompt Engineering: The Difference Between Useful and Useless
The model is only as good as your prompt. The system message is your most powerful tool - it sets the AI's persona, constraints, and output format before the user says a word.
const systemPrompt = `You are a senior full-stack developer assistant.
Rules:
- Answer only questions about web development, JavaScript, databases, and DevOps.
- If asked about unrelated topics, politely redirect to tech questions.
- Keep answers concise. Use code examples when they help.
- Format code with appropriate language identifiers.
- If unsure, say so - don't invent APIs or documentation.`;
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: systemPrompt },
...userMessages,
],
});# Rate Limiting: Protect Your Wallet
Without rate limiting, a single malicious user (or a broken frontend loop) can drain your OpenAI credits in minutes. Implement per-user limits using Redis or an in-memory store:
- Limit by IP or authenticated user ID - not just globally.
- Cap requests per minute (e.g., 10 req/min) and per day (e.g., 50 req/day).
- Return a 429 status with a `Retry-After` header when limits are hit.
- Set hard token limits in the API call (`max_tokens`) to cap cost per request.
- Consider caching identical prompts - many users ask the same questions.
Set a monthly spending limit in your OpenAI dashboard immediately. Even with rate limiting in your app, a bug could make thousands of calls. A hard cap at the provider level is your last line of defense.
