When to use route handlers vs. server actions
Use route handlers (app/api/) when:
- You need to stream a response to the browser (Server Actions can't stream yet)
- You're building an endpoint consumed by external clients (mobile apps, CLI tools)
- You need webhook handling that calls an LLM
1. Non-streaming chat endpoint
// app/api/chat/route.ts
import { auth } from "@/lib/auth";
import { openai } from "@/lib/openai";
import { NextRequest, NextResponse } from "next/server";
type Message = { role: "user" | "assistant"; content: string };
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { messages, conversationId } = (await req.json()) as {
messages: Message[];
conversationId: string;
};
const completion = await openai
.withContext({
feature: "chat-api",
user: session.user.id,
conversationId,
})
.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: "You are a helpful assistant." },
...messages,
],
});
const content = completion.choices[0].message.content;
return NextResponse.json({ content });
}
2. Streaming chat endpoint
Use the Vercel AI SDK ReadableStream pattern. FluxGate tracks the full event after the stream closes.
// app/api/chat/stream/route.ts
import { auth } from "@/lib/auth";
import { openai } from "@/lib/openai";
import { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return new Response("Unauthorized", { status: 401 });
}
const { messages, conversationId } = await req.json();
const stream = await openai
.withContext({
feature: "streaming-chat",
user: session.user.id,
conversationId,
})
.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "system", content: "You are a helpful assistant." },
...messages,
],
stream: true,
});
// Convert OpenAI AsyncIterable to a Web ReadableStream
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content ?? "";
if (delta) {
controller.enqueue(new TextEncoder().encode(delta));
}
}
controller.close();
// FluxGate tracking is finalised here, after the stream closes
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Transfer-Encoding": "chunked",
},
});
}
Client-side consumption:
// app/dashboard/chat/_components/chat.tsx
"use client";
async function sendMessage(messages: Message[], conversationId: string) {
const res = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages, conversationId }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let fullText = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
fullText += chunk;
// update your React state here to render the chunk progressively
}
return fullText;
}
3. Webhook → LLM pipeline
Process incoming events (e.g. a new support ticket) and generate an LLM response automatically.
// app/api/webhooks/support/route.ts
import { anthropic } from "@/lib/anthropic";
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
function verifySignature(body: string, signature: string): boolean {
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(`sha256=${expected}`),
);
}
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const sig = req.headers.get("x-webhook-signature") ?? "";
if (!verifySignature(rawBody, sig)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const ticket = JSON.parse(rawBody) as {
id: string;
userId: string;
subject: string;
body: string;
};
const message = await anthropic
.withContext({
feature: "support-triage",
user: ticket.userId,
sessionId: ticket.id,
})
.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 512,
messages: [
{
role: "user",
content: `Triage this support ticket and suggest a priority (low/medium/high) and a one-sentence reply draft.\n\nSubject: ${ticket.subject}\n\n${ticket.body}`,
},
],
});
const triage =
message.content[0].type === "text" ? message.content[0].text : "";
return NextResponse.json({ triage });
}
4. Embedding generation endpoint
// app/api/embeddings/route.ts
import { auth } from "@/lib/auth";
import { openai } from "@/lib/openai";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { input } = await req.json();
const result = await openai
.withContext({ feature: "semantic-search", user: session.user.id })
.embeddings.create({
model: "text-embedding-3-small",
input,
});
return NextResponse.json({ embedding: result.data[0].embedding });
}