This guide walks through a complete next.js 14 vercel ai sdk n4n.ai setup from a fresh repository to a streaming chat interface that handles provider fallback automatically. You’ll end up with a minimal but production-shaped codebase you can extend.
Step 1: Initialize the project
Start with a clean Next.js 14 app using the App Router and TypeScript. The --use-npm flag avoids yarn lockfile drift if your CI uses npm.
npx create-next-app@14 ai-chat-demo \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*" \
--use-npm
cd ai-chat-demo
Verify the scaffold works:
npm run dev
Open http://localhost:3000. You should see the default Next.js landing page.
Step 2: Install the AI SDK and n4n.ai client
The Vercel AI SDK provides the streamText helper and React hooks. The n4n.ai client is a thin wrapper around the OpenAI-compatible endpoint — no separate SDK required, just the standard openai package pointed at the n4n.ai base URL.
npm install ai openai zod
npm install -D @types/node
ai brings streamText, CoreMessage, and the useChat hook. openai is the official client that works with any OpenAI-compatible API. zod validates tool schemas at runtime.
Step 3: Configure environment variables
Create .env.local in the project root. Never commit this file.
# .env.local
N4N_API_KEY="n4n_sk_..."
N4N_BASE_URL="https://api.n4n.ai/v1"
The base URL is the only n4n.ai-specific configuration. The gateway honors standard OpenAI parameters plus a few extensions: model accepts any of the 240+ registered model IDs, and you can pass provider hints or fallback arrays in the extra_body field if you want explicit routing control.
Add the types for process.env in src/env.d.ts so TypeScript stops complaining:
// src/env.d.ts
interface ImportMetaEnv {
readonly N4N_API_KEY: string
readonly N4N_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
Restart the dev server after adding the file.
Step 4: Create the streaming API route
The App Router uses route handlers under src/app/api. Create a POST endpoint that accepts a message array, streams tokens back, and supports tool calls.
// src/app/api/chat/route.ts
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
export const maxDuration = 30
const model = openai('gpt-4o-mini', {
baseURL: process.env.N4N_BASE_URL,
apiKey: process.env.N4N_API_KEY,
})
const tools = {
getWeather: {
parameters: z.object({
latitude: z.number(),
longitude: z.number(),
}),
execute: async ({ latitude, longitude }: { latitude: number; longitude: number }) => {
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true`
)
const data = await res.json()
return data.current_weather
},
},
}
export async function POST(req: Request) {
const { messages } = await req.json()
const result = await streamText({
model,
messages,
tools,
maxSteps: 5,
temperature: 0.3,
})
return result.toDataStreamResponse()
}
Key points:
streamTexthandles the SSE framing, tool call loops, and backpressure.maxSteps: 5lets the model call tools multiple times in one turn.- The
openaifactory from@ai-sdk/openai(re-exported byai) configures the base URL and key once. toDataStreamResponse()returns aResponseobject the browser can consume viafetch+ReadableStream.
Step 5: Build the chat UI component
Create a client component that uses the useChat hook. This hook manages message state, streaming, and the input form.
// src/components/Chat.tsx
'use client'
import { useChat } from 'ai/react'
import { useState } from 'react'
export default function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
api: '/api/chat',
onError: (err) => console.error('Chat error:', err),
})
const [showError, setShowError] = useState(false)
return (
<div className="flex flex-col h-[calc(100vh-4rem)] w-full max-w-3xl mx-auto p-4">
<header className="mb-4">
<h1 className="text-2xl font-semibold">AI Chat</h1>
<p className="text-sm text-gray-500">Streaming via n4n.ai gateway</p>
</header>
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map((m) => (
<div
key={m.id}
className={`flex ${m.role === 'assistant' ? 'justify-start' : 'justify-end'}`}
>
<div
className={`max-w-[70%] rounded-2xl px-4 py-2 ${
m.role === 'assistant'
? 'bg-gray-100 text-gray-900 rounded-tl-none'
: 'bg-blue-600 text-white rounded-tr-none'
}`}
>
<p className="whitespace-pre-wrap">{m.content}</p>
{m.toolInvocations && m.toolInvocations.length > 0 && (
<details className="mt-2 text-xs opacity-70">
<summary>Tool calls</summary>
<pre className="mt-1 p-2 bg-gray-50 rounded overflow-auto">
{JSON.stringify(m.toolInvocations, null, 2)}
</pre>
</details>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-2xl rounded-tl-none px-4 py-2 animate-pulse">
<span className="text-gray-500">Thinking…</span>
</div>
</div>
)}
</div>
{error && (
<div
className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm"
role="alert"
>
{error.message}
<button
onClick={() => setShowError(true)}
className="ml-2 underline hover:text-red-900"
>
Details
</button>
</div>
)}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Ask anything…"
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Send
</button>
</form>
</div>
)
}
The hook calls POST /api/chat with the message history. It automatically appends assistant chunks as they arrive and re-renders. Tool invocations surface in message.toolInvocations for debugging.
Step 6: Wire the page
Replace the default page.tsx with the chat component.
// src/app/page.tsx
import Chat from '@/components/Chat'
export default function Home() {
return (
<main className="min-h-screen bg-white">
<Chat />
</main>
)
}
Delete the boilerplate globals.css Tailwind directives if you want a cleaner slate, but the default works fine.
Step 7: Verify the integration
Start the dev server again:
npm run dev
Open http://localhost:3000. Type a message. You should see:
- The user message appears immediately on the right.
- A “Thinking…” placeholder appears on the left.
- Tokens stream in character-by-character.
- If you ask “What’s the weather in San Francisco?”, the model calls
getWeather, the execute function fetches live data, and the final answer includes the temperature.
Open the Network tab → filter “chat” → inspect the response. You’ll see text/event-stream chunks like:
data: {"type":"text-delta","textDelta":"The"}
data: {"type":"text-delta","textDelta":" current"}
data: {"type":"tool-call","toolCallId":"call_abc","toolName":"getWeather","args":{"latitude":37.7749,"longitude":-122.4194}}
data: {"type":"tool-result","toolCallId":"call_abc","result":{"temperature":14.2,"windspeed":8.3}}
That confirms the gateway, the SDK, and your tool wiring are all connected.
Step 8: Add provider fallback (optional but recommended)
n4n.ai can automatically retry a request on a different provider when the primary hits rate limits or returns 5xx. You enable this by passing a fallback array in extra_body. Update the route handler:
// src/app/api/chat/route.ts (updated model config)
const model = openai('gpt-4o-mini', {
baseURL: process.env.N4N_BASE_URL,
apiKey: process.env.N4N_API_KEY,
extraBody: {
fallback: ['anthropic/claude-3.5-sonnet', 'google/gemini-1.5-pro'],
},
})
The gateway tries gpt-4o-mini first. If that provider returns 429 or 503, it transparently retries the same request against the next model in the list. The stream continues uninterrupted — the client sees a single SSE response.
You can also steer routing per-request from the client by sending a provider field in the request body and reading it in the route:
// In route.ts
const { messages, provider } = await req.json()
const model = openai('gpt-4o-mini', {
baseURL: process.env.N4N_BASE_URL,
apiKey: process.env.N4N_API_KEY,
extraBody: provider ? { provider } : { fallback: [...] },
})
This lets a user pick “Prefer Anthropic” or “Prefer Google” from a dropdown without changing server code.
Step 9: Meter usage per request
The gateway returns usage in the final SSE chunk and in response headers. Capture it for logging or billing:
// In route.ts, after streamText
const result = await streamText({ ... })
// Consume the stream to get usage
const { textStream, usage } = result
const fullText = []
for await (const chunk of textStream) {
fullText.push(chunk)
}
const finalUsage = await usage // resolves after stream ends
console.log('Tokens:', finalUsage)
finalUsage contains promptTokens, completionTokens, and totalTokens — aggregated across any fallback retries.
Step 10: Deploy to Vercel
Push to GitHub and import the repo in Vercel. Add the two environment variables in Project Settings → Environment Variables. The maxDuration = 30 export in the route handler tells Vercel to allow 30-second function execution (the default is 10s on Hobby, 60s on Pro).
git add .
git commit -m "Initial next.js 14 vercel ai sdk n4n.ai setup"
git push origin main
Vercel builds, deploys, and serves the same streaming endpoint at https://your-app.vercel.app/api/chat.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
TypeError: Failed to fetch in browser |
N4N_BASE_URL missing or wrong |
Check .env.local and restart dev server |
| Stream cuts off after ~10s | Vercel function timeout | Ensure export const maxDuration = 30 (or higher) in route |
| Tool calls never resolve | execute throws or returns non-serializable |
Wrap execute in try/catch; return plain objects |
| Double rendering in React 18 Strict Mode | useChat mounts twice in dev |
Expected in dev; production builds run once |
What to build next
- Persist conversations to a database (Postgres, SQLite, or Vercel KV) keyed by session ID.
- Add authentication (NextAuth.js) and associate chats with users.
- Expose a
/api/modelsendpoint that proxiesGET /modelsfrom the gateway so your UI can render a live model picker. - Implement
onToolCall/onToolResultcallbacks instreamTextfor server-side logging or audit trails. - Add request validation with
zodon the route handler to reject malformed payloads before they hit the model.
The foundation is solid: a typed, streaming, tool-capable chat loop backed by a gateway that handles provider diversity and fallback without you writing retry logic. Extend from here.