n4nAI

Persisting chat history in Next.js with the Vercel AI SDK

Practical guide to next.js persist chat history vercel ai sdk: schema, loading, streaming saves, multi-user isolation, and pitfalls using App Router.

n4n Team4 min read830 words

Audio narration

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

The Vercel AI SDK makes it trivial to stream chat completions into a React UI, but its useChat hook keeps the transcript in client memory. If you need to next.js persist chat history vercel ai sdk, you have to own the storage layer and hook it into both the initial render and the streaming lifecycle. This guide gives a concrete, production-minded pattern using Next.js App Router, a Postgres table, and the SDK’s server-side callbacks.

Data model: conversations and messages

Start with a normalized schema. A conversation groups messages; each message stores role, content, and ordering. For most chat apps, plain text is enough, but if you render tool calls or images, store the full CoreMessage as JSONB.

CREATE TABLE conversations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id TEXT NOT NULL,
  title TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE messages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
  role TEXT NOT NULL CHECK (role IN ('user','assistant','system')),
  content TEXT NOT NULL,
  data JSONB, -- optional: store parts, tool calls, attachments
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ON messages (conversation_id, created_at);

Keep writes append-only. Updates to messages are rare; if you support editing, insert a new row with a replaced_by pointer or use a version column. Don’t mutate content in place—you lose auditability.

Loading existing history

The first step to next.js persist chat history vercel ai sdk is loading prior turns into the hook on the server. In App Router, the page is a server component. Fetch the conversation and its messages, then pass them to a client component as initialMessages. The SDK expects its Message shape (id, role, content).

// app/chat/[id]/page.tsx
import { getConversation } from '@/lib/db';
import { Chat } from './chat';

export default async function Page({ params }: { params: { id: string } }) {
  const { messages, userId } = await getConversation(params.id);
  // enforce auth: throw if userId !== session.user.id
  const initialMessages = messages.map((m) => ({
    id: m.id,
    role: m.role as 'user' | 'assistant' | 'system',
    content: m.content,
  }));
  return <Chat conversationId={params.id} initialMessages={initialMessages} />;
}

On the client, feed initialMessages to useChat. The hook resumes from there; no custom state needed.

'use client';
import { useChat } from '@ai-sdk/react';

export function Chat({ conversationId, initialMessages }: {
  conversationId: string;
  initialMessages: { id: string; role: string; content: string }[];
}) {
  const { messages, input, handleInputChange, handleSubmit } = useChat({
    initialMessages,
    body: { conversationId },
  });
  return (
    <form onSubmit={handleSubmit}>
      <input value={input} onChange={handleInputChange} />
      {messages.map((m) => (
        <div key={m.id}>{m.role}: {m.content}</div>
      ))}
    </form>
  );
}

Pitfall: if you mutate initialMessages after mount, the hook ignores it. Load before render or use a key to remount the component when switching conversations.

Saving messages during streaming

The SDK’s streamText runs on the server. Use its onFinish callback to persist the assistant message only after the stream completes. Saving mid-stream risks partial content and duplicate rows on retry.

// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText, convertToCoreMessages } from 'ai';
import { saveUserMessage, saveAssistantMessage, ensureConversation } from '@/lib/db';

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

  const lastUser = messages[messages.length - 1];
  await saveUserMessage(conversationId, lastUser.content);

  const result = streamText({
    model: openai('gpt-4o'),
    messages: convertToCoreMessages(messages),
    onFinish: async ({ text }) => {
      await saveAssistantMessage(conversationId, text);
    },
  });

  return result.toDataStreamResponse();
}

We save the user message synchronously before streaming. If the request fails before streaming starts, the user message is still recorded—acceptable for most apps, but wrap both inserts in a transaction if you need atomicity.

If you use an OpenAI-compatible gateway (e.g., n4n.ai) to access multiple models with fallback, the persistence layer stays identical because the AI SDK only sees the OpenAI interface; onFinish yields the final text regardless of which backend served it.

Handling conversation creation and user isolation

Don’t trust the client to own conversation IDs without auth checks. Generate or validate the conversation server-side:

// lib/db.ts (sketch)
export async function ensureConversation(id: string, userId: string) {
  const existing = await db.query('SELECT user_id FROM conversations WHERE id=$1', [id]);
  if (existing.rows.length === 0) {
    await db.query('INSERT INTO conversations (id, user_id) VALUES ($1,$2)', [id, userId]);
  } else if (existing.rows[0].user_id !== userId) {
    throw new Error('forbidden');
  }
}

Pass userId from your session, not from the request body. Otherwise any user can append to another’s chat. For shared workspaces, add a membership check instead of a direct user_id match.

Common pitfalls and tradeoffs

Storing partial streams

Writing to the database on every token is tempting for “resume after refresh” but creates write amplification and messy states if the client disconnects. Persist only on onFinish. If you need live resilience, use a temporary cache (Redis) for in-flight transcripts and flush to Postgres on completion.

Message shape drift

The SDK’s Message type evolves (e.g., parts for tool invocations). If you store only content, you lose tool calls and images. Use the data JSONB column to store the full message object. Tradeoff: text search and analytics become harder; use a generated column if you need both.

Multi-tab sync

useChat is per-component. Opening the same conversation in two tabs creates divergent states. Solve with a subscription (Postgres LISTEN/NOTIFY or websockets) or accept last-write-wins. For most internal tools, last-write-wins is fine.

Token and context limits

Persisting history means you may send the entire transcript back to the model each turn. Implement a sliding window or summarization before calling streamText. The DB gives you the full log; trim in the API route:

const trimmed = messages.slice(-20); // simple window
const coreMessages = convertToCoreMessages(trimmed);

For long-running agents, summarize older messages with a background job and store the summary as a system message.

Serverless timeouts

On serverless (Vercel), long streams can hit function timeouts. streamText streams responses, but onFinish runs after the stream closes—if the function freezes before that, the assistant message is lost. Use export const maxDuration = 30; and consider a separate worker for the DB write if you see missing rows.

Alternative: server actions

You can skip the route handler and use a Next.js server action with streamText. The persistence code is identical; you just point useChat’s api to the action. Route handlers are simpler to debug with curl; server actions reduce boilerplate and colocate with components. Choose based on your auth layout.

'use server';
import { streamText, convertToCoreMessages } from 'ai';
import { openai } from '@ai-sdk/openai';

export async function chatAction(messages: any[], conversationId: string) {
  const result = streamText({
    model: openai('gpt-4o'),
    messages: convertToCoreMessages(messages),
    onFinish: async ({ text }) => { await saveAssistantMessage(conversationId, text); },
  });
  return result.toDataStreamResponse();
}

Testing the flow

Write an integration test that posts to the API, streams to completion, and asserts a row exists. Use a local model or mock to avoid cost.

test('persists assistant message', async () => {
  const res = await POST(new Request('http://localhost', {
    method: 'POST',
    body: JSON.stringify({ conversationId: 'test', userId: 'u1', messages: [{ role:'user', content:'hi' }] })
  }));
  await res.body?.cancel();
  const rows = await db.query('SELECT * FROM messages WHERE conversation_id=$1', ['test']);
  expect(rows.rows.length).toBe(2); // user + assistant
});

This catches missing onFinish calls early.

Migrating from memory-only

If you already shipped a prototype with useChat and no DB, introduce persistence incrementally: add the messages table, change the API route to save, and add a conversationId to the client body. Existing anonymous sessions can be backfilled by creating a conversation on first POST. No changes to the React tree are required beyond passing initialMessages.

Summary

To next.js persist chat history vercel ai sdk, treat the database as the source of truth and the SDK as a streaming helper. Load via server component, save user message pre-stream and assistant message post-stream, enforce ownership, and avoid premature optimization around partial writes. The pattern scales to thousands of conversations with proper indexing and a windowing strategy.

Tagsnextjsvercel-ai-sdkconversation-historydatabase

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 next.js ai chat integration (app router + vercel ai sdk) posts →