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:
- 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. - 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
useEffectoruseSyncExternalStore. 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: Themessagesreference inonFinishis the previous array. Use the functional formsetMessages(prev => [...prev, message])if you need to derive state, butonFinishreceives the new message as an argument — prefer that. - Cross-tab localStorage events don’t fire in the same tab: That’s why
BroadcastChannelis 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
onFinishandonError, 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.