AIAugust 30, 202511 min read

AI Meets Web: Integrating OpenAI APIs

Building an AI-powered feature isn't magic - it's an API call, a prompt, and smart UX. Here's how I integrated OpenAI into a Next.js app from scratch, including streaming, rate limiting, and keeping your API key safe.

AI Meets Web: Integrating OpenAI APIs

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.

typescript
// 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.

typescript
// 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" },
  });
}
typescript
// 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.

typescript
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.

Written by

Muhiu Din

Full-Stack Engineer

More Articles