Skip to content
CodeAndBuild LogoCodeAndBuild

Next.js

How to Integrate OpenAI & Gemini API in Next.js (Step-by-Step Guide)

Learn how to build full-stack AI applications by integrating OpenAI and Google Gemini APIs into Next.js App Router using Server Actions and Vercel AI SDK.

9 min read
  • Next.js
  • OpenAI
  • Gemini
On this page
  1. Prerequisites and package install
  2. Environment variables
  3. Stream from a route handler
  4. Render the chat with useChat
  5. Rate limits and server-side requests

A Next.js App Router app can call OpenAI and Google Gemini without exposing either API key to the browser. The route handler holds the key, streams tokens back, and a small client component renders the conversation with useChat from the Vercel AI SDK.

Prerequisites and package install

Start from an App Router project on Node.js 20 or newer. You need an OpenAI API key and a Gemini key from Google AI Studio. The AI SDK speaks to both providers through the same streaming interface, so the UI does not care which model answered.

  • A Next.js App Router app.
  • Accounts that can create OPENAI_API_KEY and GOOGLE_GENERATIVE_AI_API_KEY.
  • The ai package plus the OpenAI and Google provider packages.
terminaltext
npm install ai @ai-sdk/openai @ai-sdk/google

Environment variables

Put both keys in .env.local. The OpenAI provider reads OPENAI_API_KEY. The Google provider reads GOOGLE_GENERATIVE_AI_API_KEY. Leave the NEXT_PUBLIC_ prefix off. A public variable is shipped to the browser.

.env.localtext
OPENAI_API_KEY=your_openai_key
GOOGLE_GENERATIVE_AI_API_KEY=your_gemini_key

Restart next dev after saving the file. On Vercel, add the same names in the project environment settings for Production and Preview. Do not commit .env.local.

Stream from a route handler

Create app/api/chat/route.ts. The client sends the message history and which provider to use. The handler picks a model, streams the completion, and returns the data stream useChat expects.

app/api/chat/route.tsts
import { google } from "@ai-sdk/google";
import { openai } from "@ai-sdk/openai";
import { streamText, type CoreMessage } from "ai";

export const maxDuration = 30;

type ChatRequest = {
  messages: CoreMessage[];
  provider?: "openai" | "gemini";
};

export async function POST(request: Request) {
  const body = (await request.json()) as ChatRequest;
  const messages = Array.isArray(body.messages) ? body.messages : [];
  const model =
    body.provider === "gemini"
      ? google("gemini-2.5-flash")
      : openai("gpt-4o-mini");

  const result = streamText({
    model,
    messages,
  });

  return result.toDataStreamResponse();
}

Keep model names in this file, not in client code that you do not trust. Swapping gpt-4o-mini or gemini-2.5-flash for another model from the same provider does not change the rest of the route.

Render the chat with useChat

useChat posts to /api/chat by default, stores the messages, and appends streamed text to the latest assistant message. Pass body.provider when the reader switches between OpenAI and Gemini.

components/ChatPanel.tsxtsx
"use client";

import { useChat } from "ai/react";
import { useState } from "react";

export function ChatPanel() {
  const [provider, setProvider] = useState<"openai" | "gemini">("openai");
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: "/api/chat",
    body: { provider },
  });

  return (
    <section>
      <label>
        Provider
        <select
          value={provider}
          onChange={(event) => setProvider(event.target.value as "openai" | "gemini")}
        >
          <option value="openai">OpenAI</option>
          <option value="gemini">Gemini</option>
        </select>
      </label>
      <ol>
        {messages.map((message) => (
          <li key={message.id}>
            <strong>{message.role}</strong>
            <p>{message.content}</p>
          </li>
        ))}
      </ol>
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} placeholder="Ask a question" />
        <button type="submit" disabled={isLoading}>
          Send
        </button>
      </form>
    </section>
  );
}

Rate limits and server-side requests

The route is public as soon as you deploy it. Anyone who can reach /api/chat can spend your provider quota. Check the body, cap how often a caller can post, and keep the provider request on the server.

  1. 01

    Reject a bad body

    Require messages to be an array, cap its length, and ignore roles or fields you did not ask for.

  2. 02

    Limit bursts

    Count calls per user or per IP inside a short window. A process-local map works on one server. A shared store is required when several serverless instances run at once.

  3. 03

    Bound the work

    Set maxDuration on the route and maxTokens on streamText so one prompt cannot run until the platform kills it.

lib/rate-limit.tsts
const hits = new Map<string, { count: number; reset: number }>();

export function allowRequest(key: string, limit = 10, windowMs = 60_000) {
  const now = Date.now();
  const current = hits.get(key);

  if (!current || current.reset < now) {
    hits.set(key, { count: 1, reset: now + windowMs });
    return true;
  }

  if (current.count >= limit) return false;
  current.count += 1;
  return true;
}

Call allowRequest at the top of POST and return a 429 response when it returns false. Prefer a signed-in user id over the raw x-forwarded-for header, which a client can spoof. Log failures and status codes. Do not log the API keys or the full prompt if the conversation may contain private text.

More guides