Building reliable agentic features means letting the model trigger your code. This tutorial shows how to implement nextjs api routes function calling gpt-4o with the OpenAI Node SDK and TypeScript, from project setup to returning a grounded answer.
Prerequisites
- Node.js 18.18+ (App Router requires async request handlers, stable on 18+)
- Next.js 14+ with the
app/directory enabled - An OpenAI API key, or any OpenAI-compatible key
- Familiarity with
fetch,async/await, and TypeScript interfaces
You should be able to run npx create-next-app without prompts. No prior experience with the OpenAI tools API is required, but you should know what a chat completion is.
Project setup
Scaffold a minimal TypeScript App Router project:
npx create-next-app@latest weather-agent --typescript --app --no-tailwind --no-eslint
cd weather-agent
npm install openai
The resulting structure includes app/page.tsx and app/api/. We will add three files:
app/lib/tools.ts # tool schemas
app/lib/executors.ts # local function implementations
app/api/chat/route.ts # the API route
Create .env.local and store the key:
OPENAI_API_KEY=sk-your-key
Never expose this key to the client. The API route is the only place it should be read.
Define the tool schemas
GPT-4o consumes tools as a list of function descriptors. Each must include a JSON Schema for its parameters. We define two tools to show how multiple functions are handled.
// app/lib/tools.ts
export const tools = [
{
type: "function",
function: {
name: "get_current_weather",
parameters: {
type: "object",
properties: {
city: { type: "string", description: "City name, e.g. 'Berlin'" },
},
required: ["city"],
},
},
},
{
type: "function",
function: {
name: "get_stock_price",
parameters: {
type: "object",
properties: {
symbol: { type: "string", description: "Ticker, e.g. 'AAPL'" },
},
required: ["symbol"],
},
},
},
] as const;
The as const keeps the names literal, which helps later with switch statements.
Implement the local executors
The model emits arguments; your server runs the real logic. Stub these with deterministic data and a forced error path for demonstration.
// app/lib/executors.ts
export async function get_current_weather(city: string): Promise<string> {
const temps: Record<string, number> = { Berlin: 14, Paris: 18, NYC: 22 };
if (!(city in temps)) {
return JSON.stringify({ error: `No data for ${city}` });
}
return JSON.stringify({ city, temp_c: temps[city], unit: "celsius" });
}
export async function get_stock_price(symbol: string): Promise<string> {
const prices: Record<string, number> = { AAPL: 212.4, MSFT: 438.1 };
if (!(symbol in prices)) {
return JSON.stringify({ error: `Unknown symbol ${symbol}` });
}
return JSON.stringify({ symbol, price_usd: prices[symbol] });
}
Returning a JSON string (not an object) matches what the tool message content expects.
Build the API route
This is the core of our nextjs api routes function calling gpt-4o implementation. The handler sends the user message, inspects tool_calls, executes each, and calls the model again with the results attached.
// app/api/chat/route.ts
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";
import { tools } from "../../lib/tools";
import { get_current_weather, get_stock_price } from "../../lib/executors";
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function POST(req: NextRequest) {
const { message } = await req.json();
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "user", content: message },
];
const first = await client.chat.completions.create({
model: "gpt-4o",
messages,
tools: tools as any,
});
const assistantMsg = first.choices[0].message;
messages.push(assistantMsg);
if (!assistantMsg.tool_calls) {
return NextResponse.json({ reply: assistantMsg.content });
}
for (const call of assistantMsg.tool_calls) {
const fn = call.function;
let result: string;
if (fn.name === "get_current_weather") {
const { city } = JSON.parse(fn.arguments);
result = await get_current_weather(city);
} else if (fn.name === "get_stock_price") {
const { symbol } = JSON.parse(fn.arguments);
result = await get_stock_price(symbol);
} else {
result = JSON.stringify({ error: "unknown tool" });
}
messages.push({
role: "tool",
tool_call_id: call.id,
content: result,
});
}
const second = await client.chat.completions.create({
model: "gpt-4o",
messages,
});
return NextResponse.json({ reply: second.choices[0].message.content });
}
Raw first-call shape
For "What is the temperature in Berlin and AAPL price?", the first response contains:
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_w1",
"type": "function",
"function": { "name": "get_current_weather", "arguments": "{\"city\":\"Berlin\"}" }
},
{
"id": "call_s1",
"type": "function",
"function": { "name": "get_stock_price", "arguments": "{\"symbol\":\"AAPL\"}" }
}
]
}
}
]
}
Your route runs both executors, appends two tool messages, and GPT-4o returns a merged sentence.
Call the route from a client component
A minimal page that posts a hardcoded prompt and renders the reply:
// app/page.tsx
"use client";
import { useState } from "react";
export default function Home() {
const [reply, setReply] = useState("");
const [loading, setLoading] = useState(false);
async function ask() {
setLoading(true);
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: "What is the temperature in Berlin and AAPL price?",
}),
});
const data = await res.json();
setReply(data.reply);
setLoading(false);
}
return (
<main style={{ padding: 24 }}>
<button onClick={ask} disabled={loading}>
{loading ? "Asking..." : "Ask"}
</button>
<p>{reply}</p>
</main>
);
}
Test with curl
Start the dev server (npm run dev) and hit the route directly:
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"What is the temperature in Paris?"}'
Expected JSON:
{ "reply": "The temperature in Paris is 18°C." }
If you pass an unknown city, the executor returns an error object, and GPT-4o will typically say it couldn’t retrieve the data.
Type-safe argument parsing
The raw arguments string is untrusted. Use Zod to validate before executing:
import { z } from "zod";
const WeatherArgs = z.object({ city: z.string().min(1) });
const parsed = WeatherArgs.safeParse(JSON.parse(fn.arguments));
if (!parsed.success) {
result = JSON.stringify({ error: "invalid arguments" });
} else {
result = await get_current_weather(parsed.data.city);
}
This prevents malformed tool calls from crashing your route.
Swapping in an OpenAI-compatible gateway
The handler above targets OpenAI’s default host. If you point the client at n4n.ai’s OpenAI-compatible endpoint, the same nextjs api routes function calling gpt-4o code runs without modification and gains automatic fallback when a provider is rate-limited or degraded, plus per-token metering:
const client = new OpenAI({
apiKey: process.env.N4N_API_KEY,
baseURL: "https://api.n4n.ai/v1",
});
The tools parameter and message shapes are identical because the gateway forwards to GPT-4o.
Production hardening
- Timeouts: Wrap executor calls in
Promise.racewith a 5s timeout. A slow internal API should not hang the chat. - Idempotency: Use
tool_call_idas a dedupe key if you retry the second completion. - Logging: Log the assistant
tool_callsand your executor results. This is the fastest way to debug prompt drift. - Streaming: The final answer can be streamed by passing
stream: trueon the second call and parsing SSE on the client. The tool round-trip itself stays non-streaming.
You now have a working nextjs api routes function calling gpt-4o integration that handles multiple tools, validates input, and degrades cleanly on error.