AI Integration for Next.js + Supabase Applications
Integrate AI into Next.js and Supabase: OpenAI chat, vector search, RAG, and streaming UIs with production patterns and cost guardrails.
This guide wires AI features into a Next.js App Router project backed by Supabase: server-side OpenAI calls, a streaming chat UI with the Vercel AI SDK, chat history stored in Postgres, embeddings and similarity search with pgvector, image generation saved to Supabase Storage, and the cost and safety controls around them. Every API key stays on the server.
1. AI APIs Overview#
OpenAI#
Models: OpenAI publishes a fast/affordable tier alongside a flagship reasoning-and-coding model. Model names change as new versions ship, so check the OpenAI models overview for the current lineup rather than hardcoding a specific model name in your app.
Pricing: Token pricing varies by model tier and changes over time. Check current rates on openai.com/pricing before estimating costs for your use case.
Anthropic Claude#
Models: Anthropic also ships a fast/affordable tier and a flagship model, each replaced periodically. See the Claude models overview for the current lineup and context-window limits.
Other Providers#
- Cohere: Embeddings, classification
- Hugging Face: Open-source models
- Replicate: Image generation, specialized models
2. OpenAI Integration#
Setup#
npm install openaiModel IDs are retired on a schedule: OpenAI's deprecations page lists gpt-4-turbo-preview as shut down on 26 March 2026, dall-e-3 on 12 May 2026, and gpt-3.5-turbo as shutting down on 23 October 2026. Keep the model IDs in environment variables so a retirement is a config change, not a code change.
// lib/openai.ts
import OpenAI from 'openai'
export const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
// Pick current IDs from platform.openai.com/docs/models
export const CHAT_MODEL = process.env.OPENAI_CHAT_MODEL as string // flagship tier
export const FAST_MODEL = process.env.OPENAI_FAST_MODEL as string // cheaper tier
export const IMAGE_MODEL = process.env.OPENAI_IMAGE_MODEL as string // a GPT Image modelBasic Chat Completion#
// app/api/chat/route.ts
import { openai, CHAT_MODEL } from '@/lib/openai'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const { message } = await request.json()
const completion = await openai.chat.completions.create({
model: CHAT_MODEL,
messages: [
{
role: 'system',
content: 'You are a helpful assistant.',
},
{
role: 'user',
content: message,
},
],
})
return NextResponse.json({
response: completion.choices[0].message.content,
})
}Streaming Responses#
OpenAIStream and StreamingTextResponse no longer exist: the AI SDK 4.0 migration guide removed them in favour of streamText. The current pattern, per the AI SDK Next.js App Router guide and the OpenAI provider page, calls streamText with the @ai-sdk/openai provider and returns toUIMessageStreamResponse():
npm install ai @ai-sdk/openai @ai-sdk/react// app/api/chat/stream/route.ts
import { streamText, convertToModelMessages, type UIMessage } from 'ai'
import { openai } from '@ai-sdk/openai'
import { CHAT_MODEL } from '@/lib/openai'
export async function POST(request: Request) {
const { messages }: { messages: UIMessage[] } = await request.json()
const result = streamText({
model: openai(CHAT_MODEL),
system: 'You are a helpful assistant.',
messages: await convertToModelMessages(messages),
})
return result.toUIMessageStreamResponse()
}3. Chat Interfaces#
Client Component#
useChat now ships in @ai-sdk/react, no longer manages the input field, and sends through a transport whose default endpoint is /api/chat (useChat reference). Messages carry a parts array instead of a single content string.
'use client'
import { useState } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
export function ChatInterface() {
const [input, setInput] = useState('')
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat/stream' }),
})
const isLoading = status === 'submitted' || status === 'streaming'
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!input.trim()) return
sendMessage({ text: input })
setInput('')
}
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto p-4">
{messages.map((message) => (
<div
key={message.id}
className={`mb-4 ${
message.role === 'user' ? 'text-right' : 'text-left'
}`}
>
<div
className={`inline-block p-3 rounded-lg ${
message.role === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-black'
}`}
>
{message.parts.map((part, i) =>
part.type === 'text' ? <span key={i}>{part.text}</span> : null
)}
</div>
</div>
))}
{isLoading && (
<div className="text-left">
<div className="inline-block p-3 rounded-lg bg-gray-200">
Thinking...
</div>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="p-4 border-t">
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="flex-1 p-2 border rounded"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Send
</button>
</div>
</form>
</div>
)
}Store Chat History#
// app/api/chat/route.ts
import { createClient } from '@/lib/supabase/server'
import { openai, CHAT_MODEL } from '@/lib/openai'
export async function POST(request: Request) {
const supabase = createClient()
const { message, conversationId } = await request.json()
// Get user
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return new Response('Unauthorized', { status: 401 })
}
// Get conversation history
const { data: messages } = await supabase
.from('messages')
.select('*')
.eq('conversation_id', conversationId)
.order('created_at', { ascending: true })
// Create chat completion
const completion = await openai.chat.completions.create({
model: CHAT_MODEL,
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
...(messages ?? []).map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: message },
],
})
const assistantMessage = completion.choices[0].message.content
// Save messages
await supabase.from('messages').insert([
{
conversation_id: conversationId,
role: 'user',
content: message,
user_id: user.id,
},
{
conversation_id: conversationId,
role: 'assistant',
content: assistantMessage,
user_id: user.id,
},
])
return Response.json({ response: assistantMessage })
}4. Vector Embeddings and Search#
Supabase's vector columns guide documents the pgvector setup below, including the JS-client insert pattern and the three distance operators (<->, <#>, <=>) used for similarity search.
Enable pgvector#
-- Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
-- Create table with vector column
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
content TEXT NOT NULL,
embedding vector(1536), -- text-embedding-3-small default length
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- HNSW index for cosine-distance search
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);The column size must match the model: OpenAI's embeddings guide gives a default length of 1536 for text-embedding-3-small and 3072 for text-embedding-3-large (shortened with the dimensions parameter). Supabase's vector indexes guide recommends HNSW "because of its performance and robustness against changing data". If you use IVFFlat instead, the pgvector README says to create the index after the table has some data and to start with rows / 1000 lists for up to 1M rows, so an IVFFlat index built on an empty table with a fixed lists = 100 is the wrong default.
Generate Embeddings#
Per OpenAI's embeddings guide, text-embedding-3-small returns a vector of floating-point numbers suited for semantic search: comparing embeddings by cosine similarity ranks documents by relatedness, which is the same idea implemented via match_documents below.
// lib/embeddings.ts
import { openai } from './openai'
export async function generateEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
})
return response.data[0].embedding
}Store Documents with Embeddings#
// app/api/documents/route.ts
import { createClient } from '@/lib/supabase/server'
import { generateEmbedding } from '@/lib/embeddings'
export async function POST(request: Request) {
const supabase = createClient()
const { content, metadata } = await request.json()
// Generate embedding
const embedding = await generateEmbedding(content)
// Store document
const { data, error } = await supabase
.from('documents')
.insert({
content,
embedding,
metadata,
})
.select()
.single()
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
return Response.json({ data })
}Semantic Search#
// app/api/search/route.ts
import { createClient } from '@/lib/supabase/server'
import { generateEmbedding } from '@/lib/embeddings'
export async function POST(request: Request) {
const supabase = createClient()
const { query, limit = 5 } = await request.json()
// Generate query embedding
const queryEmbedding = await generateEmbedding(query)
// Search for similar documents
const { data, error } = await supabase.rpc('match_documents', {
query_embedding: queryEmbedding,
match_threshold: 0.7,
match_count: limit,
})
if (error) {
return Response.json({ error: error.message }, { status: 500 })
}
return Response.json({ results: data })
}-- Create similarity search function
CREATE OR REPLACE FUNCTION match_documents(
query_embedding vector(1536),
match_threshold float,
match_count int
)
RETURNS TABLE (
id uuid,
content text,
metadata jsonb,
similarity float
)
LANGUAGE sql STABLE
AS $$
SELECT
id,
content,
metadata,
1 - (embedding <=> query_embedding) AS similarity
FROM documents
WHERE 1 - (embedding <=> query_embedding) > match_threshold
ORDER BY embedding <=> query_embedding
LIMIT match_count;
$$;More Next.js and Supabase guides are collected in the Next.js + Supabase resource hub.
5. RAG (Retrieval-Augmented Generation) Systems#
RAG combines the vector search above with an LLM call: embed the user's
question, retrieve the most relevant documents from match_documents, then
pass those documents as context to the chat completion so the model answers
from your own data instead of guessing.
A full, production-grade implementation — chunking strategy, re-ranking, citation handling, and the client component — lives in a dedicated guide rather than being duplicated here: see Production RAG with Supabase pgvector and Next.js.
6. Content Generation#
Generate Blog Posts#
// app/api/generate/post/route.ts
import { openai, CHAT_MODEL } from '@/lib/openai'
export async function POST(request: Request) {
const { topic, keywords } = await request.json()
const completion = await openai.chat.completions.create({
model: CHAT_MODEL,
messages: [
{
role: 'system',
content: 'You are an expert content writer. Write engaging, SEO-optimized blog posts.',
},
{
role: 'user',
content: `Write a blog post about "${topic}". Include these keywords: ${keywords.join(', ')}.
Format:
- Compelling title
- Introduction
- 3-5 main sections with subheadings
- Conclusion
- Use markdown formatting`,
},
],
})
return Response.json({
content: completion.choices[0].message.content,
})
}Generate Product Descriptions#
// app/api/generate/description/route.ts
import { openai, FAST_MODEL } from '@/lib/openai'
export async function POST(request: Request) {
const { productName, features } = await request.json()
const completion = await openai.chat.completions.create({
model: FAST_MODEL,
messages: [
{
role: 'system',
content: 'You are a marketing copywriter. Write compelling product descriptions.',
},
{
role: 'user',
content: `Write a product description for "${productName}".
Features:
${features.map((f: string) => `- ${f}`).join('\n')}
Write 2-3 paragraphs that highlight benefits and create desire.`,
},
],
})
return Response.json({
description: completion.choices[0].message.content,
})
}7. Image Generation#
Generate and Store an Image#
dall-e-2 and dall-e-3 were removed from the API on 12 May 2026; the deprecations page points to the GPT Image models (gpt-image-1, gpt-image-1-mini, gpt-image-2). In OpenAI's image generation guide, these models return the image as base64 in data[0].b64_json, not as a temporary URL, so the route decodes it and uploads the bytes straight to Supabase Storage.
// app/api/generate/image/route.ts
import { createClient } from '@/lib/supabase/server'
import { openai, IMAGE_MODEL } from '@/lib/openai'
export async function POST(request: Request) {
const supabase = createClient()
const { prompt } = await request.json()
// Generate image (GPT Image models return base64)
const response = await openai.images.generate({
model: IMAGE_MODEL,
prompt,
})
const b64 = response.data?.[0]?.b64_json
if (!b64) {
return Response.json({ error: 'No image returned' }, { status: 502 })
}
const bytes = Buffer.from(b64, 'base64')
// Upload to Supabase Storage
const fileName = `${Date.now()}.png`
const { error: uploadError } = await supabase.storage
.from('generated-images')
.upload(fileName, bytes, { contentType: 'image/png' })
if (uploadError) {
return Response.json({ error: uploadError.message }, { status: 500 })
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('generated-images')
.getPublicUrl(fileName)
return Response.json({ imageUrl: publicUrl })
}8. Recommendations Engine#
Content-Based Recommendations with Embeddings#
This is content-based filtering: it embeds the text of items the user interacted with and returns similar items. match_items is a function you define like match_documents above, over an items table with its own embedding column.
// app/api/recommendations/route.ts
import { createClient } from '@/lib/supabase/server'
import { generateEmbedding } from '@/lib/embeddings'
export async function GET(request: Request) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
// Get user's interaction history
const { data: interactions } = await supabase
.from('user_interactions')
.select('item_id, item_content')
.eq('user_id', user.id)
.limit(10)
// Generate embedding from user's interests
const userInterests = (interactions ?? [])
.map(i => i.item_content)
.join(' ')
const userEmbedding = await generateEmbedding(userInterests)
// Find similar items
const { data: recommendations } = await supabase.rpc('match_items', {
query_embedding: userEmbedding,
match_threshold: 0.6,
match_count: 10,
})
return Response.json({ recommendations })
}9. Cost Optimization#
Caching Responses#
// lib/cache.ts
import { createClient } from '@/lib/supabase/server'
export async function getCachedResponse(key: string) {
const supabase = createClient()
const { data } = await supabase
.from('ai_cache')
.select('response')
.eq('key', key)
.single()
return data?.response
}
export async function setCachedResponse(key: string, response: string) {
const supabase = createClient()
await supabase
.from('ai_cache')
.upsert({ key, response, expires_at: new Date(Date.now() + 86400000) })
}Use Cheaper Models#
Route simple tasks to the cheaper tier and keep the flagship tier for work that needs it:
// Cheaper tier for summaries, classification, short replies
const simpleCompletion = await openai.chat.completions.create({
model: FAST_MODEL,
messages,
})
// Flagship tier only for complex reasoning
const complexCompletion = await openai.chat.completions.create({
model: CHAT_MODEL,
messages,
})Limit Token Usage#
The Chat Completions reference marks max_tokens as deprecated in favour of max_completion_tokens, and notes that max_tokens is not compatible with o-series models.
const completion = await openai.chat.completions.create({
model: CHAT_MODEL,
messages,
max_completion_tokens: 500, // cap on generated tokens, reasoning tokens included
})10. Ethical Considerations#
Content Moderation#
// app/api/moderate/route.ts
import { openai } from '@/lib/openai'
export async function POST(request: Request) {
const { text } = await request.json()
const moderation = await openai.moderations.create({
input: text,
})
const flagged = moderation.results[0].flagged
if (flagged) {
return Response.json({
allowed: false,
categories: moderation.results[0].categories,
})
}
return Response.json({ allowed: true })
}Rate Limiting#
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 h'), // 10 requests per hour
})
export async function checkAIRateLimit(userId: string) {
const { success } = await ratelimit.limit(userId)
return success
}Transparency#
Always inform users when they're interacting with AI:
<div className="ai-disclaimer">
⚠️ This response was generated by AI. Please verify important information.
</div>Frequently Asked Questions (FAQ)#
How do you integrate OpenAI with Next.js and Supabase?#
Install the OpenAI SDK (npm install openai), create an API client with your OpenAI API key, and use it in Next.js API routes or Server Components. Store conversation history and user data in Supabase for persistence and user management.
Should you use the fast/cheap model or the flagship model?#
Both OpenAI and Anthropic publish a cheaper, faster model tier alongside a flagship model — see OpenAI's models overview or Anthropic's models overview for the current lineup and pricing. Use the cheaper tier for simple tasks like basic chat, summaries, and classification. Reserve the flagship tier for complex reasoning, code generation, and tasks requiring high accuracy.
How do you implement vector search with Supabase?#
Enable the pgvector extension in Supabase, create a table with a vector column sized to your embedding model (vector(1536) for text-embedding-3-small at its default length), generate embeddings with OpenAI's embeddings API, query by cosine distance (the <=> operator) and add an HNSW index, which Supabase recommends over IVFFlat for its performance and robustness against changing data.
What is RAG and when should you use it?#
RAG (Retrieval-Augmented Generation) combines vector search with LLMs to answer questions based on your own data. Use it when you need AI to reference specific documents, knowledge bases, or proprietary information not in the LLM's training data.
How much does AI integration cost?#
Costs vary by usage, model tier, and provider, and pricing changes over time — check current rates on openai.com/pricing or anthropic.com/pricing before estimating a monthly budget, and cache repeated queries (see Cost Optimization above) to keep spend down as traffic grows.
Can you use AI features without exposing your API keys?#
Yes, always call AI APIs from server-side code (API routes, Server Components, Edge Functions). Never expose API keys in client-side code. Use environment variables and keep them server-side only.
How do you implement streaming responses?#
Use the Vercel AI SDK: call streamText with the @ai-sdk/openai provider in a route handler and return result.toUIMessageStreamResponse(), then render it with useChat from @ai-sdk/react. StreamingTextResponse and OpenAIStream were removed in AI SDK 4.0, so older tutorials that import them no longer compile.
What's the best way to cache AI responses?#
Cache responses in Supabase with a hash of the input as the key. Set expiration times based on how often content changes. This reduces API costs and improves response times for repeated queries.
How do you moderate AI-generated content?#
Use OpenAI's Moderation API to check content for harmful material before displaying it. Implement rate limiting to prevent abuse, and always show disclaimers that content is AI-generated.
Can you fine-tune models with your own data?#
Yes, on the specific models OpenAI lists in its supervised fine-tuning guide (the gpt-4.1 snapshots when this was checked, each with an announced shutdown date). That guide positions fine-tuning for output format and instruction-following problems; when the model lacks facts from your own documents, retrieval (RAG) supplies them at query time instead.
How do you handle AI API errors?#
Implement retry logic with exponential backoff for rate limits and temporary failures. Show user-friendly error messages, log errors for debugging, and have fallback responses when AI services are unavailable.
What's the difference between embeddings and completions?#
Embeddings convert text into numerical vectors for similarity search and clustering. Completions generate new text based on prompts. Use embeddings for search and recommendations, completions for chat and content generation.
Conclusion#
Start with a server-side chat route and streaming UI, then add embeddings and match_documents for semantic search, and move to the Production RAG with Supabase pgvector and Next.js guide when answers must come from your own documents. Keep model IDs in environment variables, check the deprecations page before each upgrade, and keep every AI call behind authentication and rate limiting.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
Related Guides
Production RAG with Supabase pgvector and Next.js
Build a production-grade RAG app with Supabase pgvector, Next.js App Router, hybrid search, reranking, streaming responses, evals, and cost tracking.
Best Ollama Models for n8n AI Agents (2026 Guide)
Build n8n AI agent workflows with Ollama local LLMs — best models, setup, working examples. No OpenAI bills, full data privacy.
n8n Beginner Guide 2026: Build Your First Automation
Learn n8n from scratch: installation, core concepts, your first workflow, and real automation examples that save hours every week.

