n4nAI

Set up Next.js 14 with Vercel AI SDK and n4n.ai

A step-by-step guide to wiring Next.js 14, Vercel AI SDK, and n4n.ai into a working chat interface with streaming, tool calls, and provider fallback.

n4n Team4 min read846 words

Audio narration

Coming soon — every post will get a voice note here.

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}&current_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:

  • streamText handles the SSE framing, tool call loops, and backpressure.
  • maxSteps: 5 lets the model call tools multiple times in one turn.
  • The openai factory from @ai-sdk/openai (re-exported by ai) configures the base URL and key once.
  • toDataStreamResponse() returns a Response object the browser can consume via fetch + 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:

  1. The user message appears immediately on the right.
  2. A “Thinking…” placeholder appears on the left.
  3. Tokens stream in character-by-character.
  4. 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.

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/models endpoint that proxies GET /models from the gateway so your UI can render a live model picker.
  • Implement onToolCall / onToolResult callbacks in streamText for server-side logging or audit trails.
  • Add request validation with zod on 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.

Tagsvercel-ai-sdknext-jsn4n-aisetup

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vercel ai sdk getting started with n4n.ai posts →