← the writing custom software & architecture 10 min updated

Build an AI Agent With Vercel AI SDK and Supabase

I spent three days wiring up an AI agent for a client project using Vercel AI SDK and Supabase. Here's everything that worked, everything that didn't, and the exact patterns I'd use again.

Vintage telephone switchboard with tangled copper wires lit by a single warm overhead bulb, shot on 35mm film

Three weeks ago a client rang me up, a SaaS founder based in Edinburgh, and asked whether I could build him a "smart assistant" that could answer questions about his product database, remember past conversations, and take actions like creating support tickets. The budget was tight, the deadline was tighter. I'd been meaning to properly dig into the Vercel AI SDK for a while, and this felt like the moment.

What followed was three days of actual building: some elegant bits, some embarrassing mistakes, a lot of reading source code. This post is the working tutorial I wish I'd had at the start.

---

What We're Actually Building

An AI agent. Not a chatbot. The distinction matters more than people think.

A chatbot takes input and produces output. An agent does that and decides what tools to call, in what order, and can loop back on itself when something fails. It has memory across sessions. It can act, not just respond.

Our agent will:

  • Take natural-language questions from users
  • Query a Supabase Postgres database using tool calls
  • Remember conversation history across sessions (persisted in Supabase)
  • Return structured, grounded answers

The stack is Next.js (App Router), Vercel AI SDK, Supabase for both the database and auth, and OpenAI's gpt-4o as the model. You could swap OpenAI for Anthropic or Mistral with about ten lines of change, the SDK abstracts the provider cleanly.

---

Project Setup

Start with a fresh Next.js project.

`` npx create-next-app@latest ai-agent --typescript --app --tailwind cd ai-agent ``

Install the dependencies you actually need:

`` npm install ai @ai-sdk/openai @supabase/supabase-js @supabase/ssr zod ``

Zod does schema validation on your tool inputs. Without it you're trusting the model to pass sane arguments, which it usually does until it doesn't.

Set your environment variables in .env.local:

`` OPENAI_API_KEY=sk-... NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... SUPABASE_SERVICE_ROLE_KEY=eyJ... ``

Use the service role key server-side only. The anon key is fine for client-side auth flows. Never mix these up. (I did, on a staging environment in 2022, and briefly gave every user admin-level read access to a client's CRM. Not a fun Friday afternoon.)

---

Setting Up Supabase

You need two things from Supabase: a table for your actual data, and a table for conversation memory.

The Data Table

For this tutorial, say you're building on top of a product catalogue. Run this in the Supabase SQL editor:

``sql create table products ( id uuid primary key default gen_random_uuid(), name text not null, description text, price_gbp numeric(10, 2), category text, in_stock boolean default true, created_at timestamptz default now() ); ``

Seed it with 20-30 rows. Realistic data makes testing significantly better, I always use Mockaroo for this because it generates domain-specific values rather than "string1, string2" nonsense.

The Memory Table

This is where conversation history lives between sessions.

```sql create table conversation_messages ( id uuid primary key default gen_random_uuid(), session_id text not null, role text not null check (role in ('user', 'assistant', 'tool')), content jsonb not null, created_at timestamptz default now() );

create index on conversation_messages (session_id, created_at); ```

The jsonb type on content is intentional. The AI SDK passes message content as structured objects (text parts, tool call parts, tool result parts), not plain strings. Storing it as text and then trying to parse it back is a headache you don't need.

---

The Core Agent Route

Create app/api/agent/route.ts. This is where the actual logic lives.

```typescript import { openai } from '@ai-sdk/openai'; import { streamText, tool } from 'ai'; import { createClient } from '@supabase/supabase-js'; import { z } from 'zod';

const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! );

export async function POST(req: Request) { const { messages, sessionId } = await req.json();

// Load history from Supabase const { data: history } = await supabase .from('conversation_messages') .select('role, content') .eq('session_id', sessionId) .order('created_at', { ascending: true }) .limit(40);

const priorMessages = (history ?? []).map((row) => ({ role: row.role, content: row.content, }));

const allMessages = [...priorMessages...messages];

const result = await streamText({ model: openai('gpt-4o'), system: You are a helpful product assistant. You have access to a product database. Always use the queryProducts tool when the user asks about products, pricing, or availability. Never guess at product details. If the tool returns no results, say so plainly., messages: allMessages, tools: { queryProducts: tool({ description: 'Query the product catalogue by category, name, or stock status.', parameters: z.object({ category: z.string().optional().describe('Product category to filter by'), searchTerm: z.string().optional().describe('Name or keyword to search'), inStockOnly: z.boolean().optional().describe('Filter to in-stock products only'), }), execute: async ({ category, searchTerm, inStockOnly }) => { let query = supabase.from('products').select('*');

if (category) query = query.eq('category', category); if (inStockOnly) query = query.eq('in_stock', true); if (searchTerm) query = query.ilike('name', %${searchTerm}%);

const { data, error } = await query.limit(10);

if (error) return { error: error.message }; return { products: data ?? [] }; }, }), }, maxSteps: 5, onFinish: async ({ response }) => { // Persist the new messages const newMessages = response.messages.map((msg) => ({ session_id: sessionId, role: msg.role, content: msg.content, }));

await supabase.from('conversation_messages').insert(newMessages); }, });

return result.toDataStreamResponse(); } ```

A few things worth calling out here.

maxSteps: 5 is the agent loop. The SDK will keep calling tools and feeding results back to the model, up to five times, before it forces a final answer. Set this too low and the agent gives up mid-task. Set it too high and a confused model can rack up API costs fast. Five is a sensible default for most tasks.

The onFinish callback is where you persist memory. Don't try to save messages before the stream completes, you won't have the full tool call/result pairs yet.

---

Building the Frontend

Keep this simple. A useChat hook from the AI SDK does almost all the heavy lifting.

```typescript // app/page.tsx 'use client';

import { useChat } from 'ai/react'; import { useState } from 'react';

export default function AgentPage() { const [sessionId] = useState(() => crypto.randomUUID());

const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ api: '/api/agent', body: { sessionId }, });

return ( <div className="max-w-2xl mx-auto p-6"> <div className="space-y-4 mb-6"> {messages.map((m) => ( <div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}> <span className="inline-block bg-gray-100 rounded px-3 py-2 text-sm"> {typeof m.content === 'string' ? m.content : '[tool interaction]'} </span> </div> ))} </div> <form onSubmit={handleSubmit} className="flex gap-2"> <input value={input} onChange={handleInputChange} className="flex-1 border rounded px-3 py-2 text-sm" placeholder="Ask about products..." disabled={isLoading} /> <button type="submit" disabled={isLoading} className="bg-black text-white px-4 py-2 rounded text-sm"> Send </button> </form> </div> ); } ```

The sessionId is generated once per page mount. In a real app, tie this to your auth user's ID or a persisted session cookie. Otherwise every page refresh is a fresh memory wipe.

---

Making the Agent Actually Useful

Here's the thing: a basic agent that queries a database is a demo. A useful agent handles edge cases. Specifically:

Tool Errors Gracefully

If your execute function throws, the SDK catches it and passes the error string back to the model. The model will usually try to explain the error to the user, which is fine. But you want your execute functions to return errors as data rather than throw them, the model handles returned error objects better than caught exceptions in my experience.

Grounding and Hallucination

The system prompt matters enormously. "Never guess at product details" is not fluff, without that instruction, gpt-4o will occasionally fabricate product specs when a query returns nothing. Honest to god, I tested this without the guard, asked about a product that didn't exist, and the model invented a plausible-sounding price and description. Impressive and completely useless.

Controlling History Length

Loading 40 messages from history (the .limit(40) above) is a reasonable ceiling. Beyond that you're burning tokens on old context that rarely helps. For longer-running agents, look at summarisation: every 30 messages, ask the model to summarise the conversation so far and store that as a single "summary" message.

The Vercel AI SDK docs on multi-step tool calls go into more depth on the message format if you want to dig into how tool result parts are structured.

---

Deploying to Vercel

Assuming you're using the App Router, this is genuinely simple.

  1. Push to GitHub.
  2. Import the repo in the Vercel dashboard.
  3. Add your environment variables in project settings.
  4. Deploy.

The one gotcha: streaming responses require a runtime that supports Web Streams. The App Router on Vercel handles this out of the box. If you're on Pages Router with an older Express-style API, you'll need to configure things differently, honestly, just use the App Router.

Supabase connection pooling is worth checking too. Supabase projects on the free tier have a connection limit of around 60. If you're hitting the database on every agent request with multiple queries, you might saturate that faster than you'd expect under load. Use Supabase's connection pooler (PgBouncer) in transaction mode for production deployments.

---

What I'd Do Differently Next Time

When I shipped this for the Edinburgh client, a few things bit me:

  • I initially stored message content as text in Supabase, not jsonb. Reconstructing structured tool messages from a string was genuinely painful and I wasted most of an afternoon on it.
  • I didn't add rate limiting on the route. The client's team immediately started hammering it with long, complex queries during UAT. Add something like Upstash Rate Limit before you hand anything to humans.
  • I set maxSteps to 10 in initial testing. On one badly-worded query the model looped through six tool calls before it concluded the product didn't exist. That's six database round-trips and a lot of tokens. Five is almost always enough.

The core architecture, though, held up fine. Vercel AI SDK saved me from writing my own streaming parser and tool-call state machine, which alone was worth the dependency.

---

FAQ

What models work with the Vercel AI SDK besides OpenAI?

The SDK supports Anthropic (Claude 3.5 Sonnet and others), Google (Gemini), Mistral, Cohere, and more via provider packages like @ai-sdk/anthropic . Swapping providers is genuinely just one line in most cases, replace openai('gpt-4o') with anthropic('claude-3-5-sonnet-20241022') and the streaming, tool-calling, and useChat hooks all work identically. Some providers have quirks around tool-calling support, so check the SDK compatibility table before committing.

Can the agent write back to Supabase, not just read?

Yes, and this is where agents get interesting and dangerous in equal measure. You can write a createSupportTicket or updateProductStock tool the same way you wrote queryProducts . The execute function just runs an insert or update instead of a select. I'd strongly recommend row-level security policies on any table the agent can write to, and keep destructive operations (delete) entirely out of the tool set unless you have a confirmation step in your UI.

How do I handle authentication so users only see their own data?

The cleanest approach: generate the Supabase client inside the route handler using the user's JWT (from a cookie or Authorization header) rather than the service role key. That way Supabase's row-level security policies apply automatically. The @supabase/ssr package has helpers for pulling the session from Next.js cookies. Don't pass user IDs as plain parameters to the agent, the model might be tricked into querying another user's data if the system prompt isn't airtight.

Is the Vercel AI SDK production-ready?

I'd call it production-ready with caveats. It's actively maintained, Vercel ships features at a fast clip, and the core streaming and tool-calling primitives are stable. The parts that move around more are the experimental features (like generateObject with complex union schemas). Pin your version and read the changelog before upgrading. Seahawk has two live client projects running on it now without issues, but we version-pin aggressively.

Why Supabase specifically and not Postgres on Railway or PlanetScale?

Supabase gives you Postgres plus a typed client, auth, real-time, storage, and edge functions under one roof. For a project like this, the auth integration and the SQL editor for quick iteration are genuinely time-saving. That said, the actual agent code works with any Postgres-compatible database. Swap the Supabase client for pg or Drizzle ORM and nothing structural changes.

---

Honestly, this stack surprised me with how fast it is to go from zero to a working, memory-persistent agent. The Vercel AI SDK does a lot of invisible work: streaming protocol, tool call serialisation, provider abstraction. Supabase handles persistence and auth without asking much of you. The complexity left, which is the part nobody can abstract for you, is writing a system prompt that makes the model actually behave. That bit takes iteration. Start strict, loosen gradually, and test with the worst-phrased questions you can imagine.

Related reading: AI search keyword research in 2026: what it is, why traditio, technical SEO, and AI search.

this post in the tree

Need this done, not just read?

start a project book 30 minutes