n4nAI

Vercel AI SDK useChat: message history and persistence

A step-by-step guide to implementing message history and persistence with Vercel AI SDK's useChat hook, covering local storage, server-side persistence, and streaming state recovery.

n4n Team4 min read942 words

Audio narration

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

The Vercel AI SDK’s useChat hook handles streaming responses out of the box, but message history and persistence require deliberate implementation. This usechat message history persistence tutorial walks through the complete flow: keeping history in sync during streaming, surviving page reloads, and restoring state without duplicating messages or breaking the assistant’s context window.

Step 1: Understand the default behavior

useChat maintains an in-memory messages array that updates as chunks arrive. The hook exposes append, reload, and stop functions, but it does not persist anything. On unmount or navigation, the history disappears. The first decision is where persistence lives: client-side (localStorage, IndexedDB) or server-side (database). Most production apps need both — local for instant restore, server for cross-device sync.

// Default useChat — no persistence
import { useChat } from 'ai/react';

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit } = useChat({
    api: '/api/chat',
  });

  return (
    <div>
      {messages.map(m => (
        <div key={m.id} className={m.role}>
          {m.content}
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

Step 2: Add client-side hydration with localStorage

Persist the messages array to localStorage on every change. Use a useEffect with a stable key. Guard against hydration mismatch by reading from localStorage only after mount.

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

const STORAGE_KEY = 'chat-history';

export default function Chat() {
  const [hydrated, setHydrated] = useState(false);
  const [initialMessages, setInitialMessages] = useState<UIMessage[]>([]);

  // Read once after mount
  useEffect(() => {
    try {
      const stored = localStorage.getItem(STORAGE_KEY);
      if (stored) {
        setInitialMessages(JSON.parse(stored));
      }
    } catch {
      // Corrupt data — start fresh
    }
    setHydrated(true);
  }, []);

  const { messages, setMessages, ...rest } = useChat({
    api: '/api/chat',
    initialMessages: hydrated ? initialMessages : [],
    onFinish: (message) => {
      // Persist after each completed turn
      const updated = [...messages, message];
      localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
    },
    onError: (error) => {
      console.error('Chat error:', error);
    },
  });

  // Keep localStorage in sync during streaming
  useEffect(() => {
    if (hydrated) {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
    }
  }, [messages, hydrated]);

  if (!hydrated) return <div>Loading…</div>;

  return (
    <div>
      {messages.map(m => (
        <div key={m.id} className={m.role}>
          {m.content}
        </div>
      ))}
      {/* form omitted for brevity */}
    </div>
  );
}

Verify: Open DevTools → Application → Local Storage. Send a message, wait for the stream to complete, refresh the page. The full conversation should reappear instantly.

Step 3: Handle streaming state without duplicating messages

The messages array during streaming includes a partial assistant message with role: 'assistant' and incomplete content. Persisting every keystroke floods localStorage and creates duplicate entries on reload. Instead, persist only on onFinish (complete message) and onError (failed turn). The hook already deduplicates by id, but localStorage doesn’t know that.

const { messages, setMessages, ...rest } = useChat({
  api: '/api/chat',
  initialMessages: hydrated ? initialMessages : [],
  onFinish: (message, { usage, finishReason }) => {
    // message is the complete assistant message
    const updated = [...messages, message];
    localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
  },
  onError: (error, { messages: currentMessages }) => {
    // Persist whatever we have so the user can retry
    localStorage.setItem(STORAGE_KEY, JSON.stringify(currentMessages));
  },
});

Verify: Stream a long response. Disconnect network mid-stream. Refresh. The partial assistant message should not appear — only completed turns persist.

Step 4: Implement server-side persistence

Client-only storage fails when users switch devices or clear browser data. Add a server endpoint that owns the canonical history. The pattern: on onFinish, POST the new message pair (user + assistant) to your API. On initial load, fetch history from the server instead of localStorage.

// app/api/chat/history/route.ts
import { createServerClient } from '@/lib/supabase';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(req: NextRequest) {
  const supabase = createServerClient();
  const { searchParams } = new URL(req.url);
  const sessionId = searchParams.get('sessionId');

  if (!sessionId) {
    return NextResponse.json({ messages: [] });
  }

  const { data, error } = await supabase
    .from('chat_messages')
    .select('*')
    .eq('session_id', sessionId)
    .order('created_at', { ascending: true });

  if (error) return NextResponse.json({ error: error.message }, { status: 500 });

  // Transform to UIMessage shape
  const messages = data.map(row => ({
    id: row.id,
    role: row.role,
    content: row.content,
    createdAt: row.created_at,
  }));

  return NextResponse.json({ messages });
}

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

  const { error } = await supabase
    .from('chat_messages')
    .upsert(messages.map(m => ({
      id: m.id,
      session_id: sessionId,
      role: m.role,
      content: m.content,
      created_at: m.createdAt || new Date().toISOString(),
    })));

  if (error) return NextResponse.json({ error: error.message }, { status: 500 });

  return NextResponse.json({ ok: true });
}

Update the client to fetch on mount and push on finish:

useEffect(() => {
  async function loadHistory() {
    const res = await fetch(`/api/chat/history?sessionId=${sessionId}`);
    const { messages } = await res.json();
    setInitialMessages(messages);
    setHydrated(true);
  }
  loadHistory();
}, [sessionId]);

const { messages, ...rest } = useChat({
  api: '/api/chat',
  initialMessages: hydrated ? initialMessages : [],
  onFinish: async (message) => {
    const updated = [...messages, message];
    // Fire-and-forget to server
    fetch('/api/chat/history', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ sessionId, messages: updated }),
    }).catch(console.error);
    localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
  },
});

Verify: Open two browser tabs (or incognito) with the same sessionId. Send a message in one. Refresh the other — history appears without localStorage.

Step 5: Recover streaming state on reload

If the user refreshes mid-stream, the assistant’s partial response is lost. The user sees their last message with no reply. Two strategies:

  1. Optimistic reload: Call reload() on mount if the last message is from the user and has no assistant response. This re-sends the last user message to the API.
  2. Server-side stream resume: Store the provider’s stream state (not trivial; most providers don’t support resume).

Strategy 1 is practical. Implement a useReloadOnMount hook:

function useReloadOnMount({ messages, reload, enabled = true }) {
  useEffect(() => {
    if (!enabled || messages.length === 0) return;
    const last = messages[messages.length - 1];
    if (last.role === 'user') {
      // Check if an assistant response exists for this user message
      const hasResponse = messages.some(
        m => m.role === 'assistant' && m.id > last.id
      );
      if (!hasResponse) {
        reload();
      }
    }
  }, []); // Run once on mount
}

// In your Chat component:
useReloadOnMount({ messages, reload: rest.reload });

Verify: Start a stream. Refresh mid-response. The last user message re-triggers, and a new stream begins.

Step 6: Prune history to fit context windows

Unbounded history breaks token limits and degrades latency. Implement a sliding window that keeps the system prompt, the last N turns, and optionally a summary of earlier turns.

function pruneMessages(messages: UIMessage[], maxTurns = 10): UIMessage[] {
  const systemMessages = messages.filter(m => m.role === 'system');
  const conversation = messages.filter(m => m.role !== 'system');

  // Keep last N user-assistant pairs
  const turns: UIMessage[] = [];
  for (let i = conversation.length - 1; i >= 0; i -= 2) {
    const assistant = conversation[i];
    const user = conversation[i - 1];
    if (assistant.role === 'assistant' && user?.role === 'user') {
      turns.unshift(user, assistant);
      if (turns.length / 2 >= maxTurns) break;
    }
  }

  return [...systemMessages, ...turns];
}

// Apply in onFinish before persisting
onFinish: (message) => {
  const updated = pruneMessages([...messages, message]);
  localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
  // Also send pruned version to server
  fetch('/api/chat/history', { /* ... */ });
},

Verify: Send 20 messages. Inspect localStorage — only the last 10 turns plus system prompt remain. The API receives the same pruned array.

Step 7: Handle branching and regeneration

useChat supports experimental_prepareRequestBody for custom request shaping, but branching (editing a prior user message and re-generating) requires manual setMessages manipulation. The pattern: truncate history at the edited message, update its content, then call append with the new user message to trigger a fresh stream.

function handleEditMessage(editedId: string, newContent: string) {
  const index = messages.findIndex(m => m.id === editedId);
  if (index === -1) return;

  // Keep everything up to and including the edited user message
  const truncated = messages.slice(0, index + 1);
  const updated = truncated.map(m =>
    m.id === editedId ? { ...m, content: newContent } : m
  );

  setMessages(updated);
  // Trigger regeneration from the edited message
  append({ role: 'user', content: newContent, id: generateId() });
}

Verify: Edit the third user message in a 10-turn conversation. The assistant re-streams from that point. History after the edit point disappears (as expected).

Step 8: Sync across tabs with BroadcastChannel

If a user has multiple tabs open, localStorage changes in one tab don’t automatically reflect in others. Use BroadcastChannel for instant cross-tab sync without polling.

useEffect(() => {
  const channel = new BroadcastChannel('chat-sync');

  channel.onmessage = (event) => {
    if (event.data.type === 'HISTORY_UPDATE') {
      setMessages(event.data.messages);
    }
  };

  // Broadcast on local change
  const handleStorage = (e: StorageEvent) => {
    if (e.key === STORAGE_KEY && e.newValue) {
      channel.postMessage({
        type: 'HISTORY_UPDATE',
        messages: JSON.parse(e.newValue),
      });
    }
  };

  window.addEventListener('storage', handleStorage);
  return () => {
    window.removeEventListener('storage', handleStorage);
    channel.close();
  };
}, []);

Verify: Open two tabs. Send a message in one. The other updates within milliseconds without refresh.

Step 9: Add optimistic UI for perceived speed

While the stream starts, show the user’s message immediately with a pending indicator. useChat already does this via the messages array — the user message appears before onFinish. Ensure your render handles status: 'streaming' on the assistant message.

{messages.map(m => (
  <div key={m.id} className={`${m.role} ${m.status || ''}`}>
    {m.content}
    {m.role === 'assistant' && m.status === 'streaming' && <span className="cursor">▌</span>}
  </div>
))}

No extra code needed — useChat sets status on the streaming message. Just style it.

Step 10: Test failure modes

Write integration tests for the critical paths. Use Vitest + React Testing Library with a mocked API route.

// chat.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Chat } from './Chat';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.post('/api/chat', async ({ request }) => {
    const body = await request.json();
    const lastUser = body.messages.filter((m: any) => m.role === 'user').pop();
    return new HttpResponse(
      new ReadableStream({
        start(controller) {
          controller.enqueue(`data: ${JSON.stringify({ content: 'Hello' })}\n\n`);
          controller.enqueue(`data: ${JSON.stringify({ content: ' world' })}\n\n`);
          controller.close();
        },
      }),
      { headers: { 'Content-Type': 'text/event-stream' } }
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('persists history to localStorage after stream completes', async () => {
  render(<Chat />);
  await userEvent.type(screen.getByRole('textbox'), 'Hi');
  await userEvent.click(screen.getByRole('button', { name: /send/i }));

  await waitFor(() => {
    expect(screen.getByText('Hello world')).toBeInTheDocument();
  });

  const stored = JSON.parse(localStorage.getItem('chat-history')!);
  expect(stored).toHaveLength(2);
  expect(stored[0].role).toBe('user');
  expect(stored[1].role).toBe('assistant');
  expect(stored[1].content).toBe('Hello world');
});

test('restores history on mount', async () => {
  localStorage.setItem('chat-history', JSON.stringify([
    { id: '1', role: 'user', content: 'Previous' },
    { id: '2', role: 'assistant', content: 'Restored' },
  ]));

  render(<Chat />);

  await waitFor(() => {
    expect(screen.getByText('Previous')).toBeInTheDocument();
    expect(screen.getByText('Restored')).toBeInTheDocument();
  });
});

Run the suite. Both tests should pass.

Common pitfalls

  • Hydration mismatch: Always gate localStorage reads behind a useEffect or useSyncExternalStore. Never read during SSR.
  • Duplicate IDs: Generate IDs with crypto.randomUUID() or a monotonic counter. The SDK expects unique IDs for deduplication.
  • Stale closures in onFinish: The messages reference in onFinish is the previous array. Use the functional form setMessages(prev => [...prev, message]) if you need to derive state, but onFinish receives the new message as an argument — prefer that.
  • Cross-tab localStorage events don’t fire in the same tab: That’s why BroadcastChannel is necessary for instant sync.
  • Provider streaming formats differ: Vercel AI SDK normalizes OpenAI, Anthropic, and others to the same data: event stream. If you proxy through a gateway like n4n.ai, the normalized stream works identically — no client changes needed.

Summary checklist

  • Hydrate from localStorage after mount, not during render
  • Persist only on onFinish and onError, not on every chunk
  • Fetch server-side history on initial load for cross-device support
  • Prune to a sliding window before persisting
  • Implement reload() on mount for interrupted streams
  • Support message editing with truncation + append
  • Sync tabs via BroadcastChannel
  • Test persistence, restore, reload, and pruning paths

This pattern scales from a weekend prototype to a multi-device production chat interface. The key insight: treat the client as a cache, the server as source of truth, and the streaming state as ephemeral — persist only what the user would expect to see after a refresh.

Tagsusechatmessage-historypersistencevercel-ai-sdk

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 streaming chat ui (usechat) posts →