Replay on reload
Persisting is only half the job. On reload the UI needs to show the old messages instantly, and the next agent turn needs to include them. useChat accepts initialMessages so you hand it the history from the server and everything else keeps working.
import { db } from '@/db/client';
import { messages as messagesTable } from '@/db/schema';
import { eq, asc } from 'drizzle-orm';
import { ChatPanel } from '@/components/chat/chat-panel';
export default async function ThreadPage({
params,
}: { params: Promise<{ id: string }> }) {
const { id } = await params;
const rows = await db
.select()
.from(messagesTable)
.where(eq(messagesTable.threadId, id))
.orderBy(asc(messagesTable.createdAt));
const initialMessages = rows.map((r) => ({
id: r.id,
role: r.role as 'user' | 'assistant' | 'system',
parts: r.parts as { type: string }[],
}));
return <ChatPanel threadId={id} initialMessages={initialMessages} />;
}Server component fetches the thread, the client ChatPanel receives it as props. No client-side loading spinner, the messages render during SSR.
'use client';
import { useChat } from '@ai-sdk/react';
type Props = {
threadId: string;
initialMessages: Array<{ id: string; role: 'user' | 'assistant' | 'system'; parts: Array<{ type: string }> }>;
};
export function ChatPanel({ threadId, initialMessages }: Props) {
const { messages, sendMessage, status, stop } = useChat({
api: '/api/chat',
initialMessages,
body: { threadId },
});
// render as before
}initialMessages hydrates the chat. body: { threadId } is sent with every request so the route handler knows which thread to load and append to.
Checkpoint: Full-stack agent checkpoint
Loading practice…