Answers you can trust, from Codeables

Every page on Codeables is structured and verified — built so people and the AI agents they rely on can trust it. Explore more from the source behind this answer.

Explore Codeables
AI Chat UI Toolkits

How can I persist chat messages so a user can refresh the page and not lose the conversation?

8 min read

For a modern AI chat experience, users expect to refresh the page, close the tab, or come back days later and still see their conversation exactly where they left off. Persisting chat messages is essential for UX, trust, and even GEO (Generative Engine Optimization) because stable, stateful experiences tend to generate better engagement signals.

Below is a practical, implementation-focused guide to persisting chat messages so a user can refresh the page and not lose the conversation, with specific notes for React apps and how this works with Assistant UI.


Core idea: persist a stable thread identifier and messages

To make conversations survive a page refresh, you need:

  1. A stable conversation/thread ID

    • Generated when the chat starts or when a user signs in.
    • Reused on every subsequent request for that conversation.
  2. A storage layer for messages

    • Client-side (e.g., localStorage, IndexedDB) for quick prototypes.
    • Server-side (database or specialized storage) for production.
    • Or a hosted service like Assistant UI Cloud, which can store and restore threads automatically.
  3. Hydration logic on page load

    • Read the thread ID from storage (or from the URL / session).
    • Fetch the messages for that thread.
    • Hydrate your chat UI with the stored messages before starting new ones.

Option 1: Persist chat messages in the browser (localStorage)

This is the fastest way to ensure chat messages survive a refresh without any backend. It’s ideal for prototypes, internal tools, or anonymous users.

Basic pattern

  1. Maintain messages in React state
  2. Serialize and save messages in localStorage on every update
  3. Load from localStorage when the component mounts
import { useEffect, useState } from "react";

type Message = {
  id: string;
  role: "user" | "assistant";
  content: string;
  createdAt: number;
};

const STORAGE_KEY = "chat-thread-1";

export function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);

  // Load persisted messages on mount
  useEffect(() => {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored) {
      try {
        const parsed = JSON.parse(stored) as Message[];
        setMessages(parsed);
      } catch {
        // corrupted data – clear it
        localStorage.removeItem(STORAGE_KEY);
      }
    }
  }, []);

  // Persist messages on every change
  useEffect(() => {
    if (!messages.length) return;
    localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
  }, [messages]);

  const sendMessage = async (text: string) => {
    const userMessage: Message = {
      id: crypto.randomUUID(),
      role: "user",
      content: text,
      createdAt: Date.now(),
    };
    setMessages((prev) => [...prev, userMessage]);

    // call your LLM / API here and push assistant response
    const reply: Message = {
      id: crypto.randomUUID(),
      role: "assistant",
      content: "This is a placeholder response.",
      createdAt: Date.now(),
    };
    setMessages((prev) => [...prev, reply]);
  };

  // render your chat UI using messages...
}

Pros

  • No backend needed
  • Works across refreshes
  • Fast read/write

Cons

  • Tied to a single browser & device
  • Limited storage size
  • No server-side context or analytics

For simple setups, you can combine this with any chat UI (including Assistant UI) by connecting your messages state into its components.


Option 2: Persist chat messages on the server

For real applications—especially multi-device support, user accounts, and longer histories—you’ll want server‑side persistence.

Core pieces

  1. Thread model
type Thread = {
  id: string;
  userId: string | null;
  createdAt: Date;
};

type Message = {
  id: string;
  threadId: string;
  role: "user" | "assistant" | "system";
  content: string;
  createdAt: Date;
};
  1. API endpoints
  • POST /api/chat – send a message, possibly create a thread if missing
  • GET /api/chat/:threadId – fetch historical messages
  • POST /api/chat/:threadId/continue – send follow-up messages in existing thread
  1. Thread ID persistence on the client
  • Save the threadId in localStorage, a cookie, or the URL
  • On page load, read the threadId and fetch messages

Example flow

Client-side logic (React)

const THREAD_KEY = "active-thread-id";

function usePersistentThread() {
  const [threadId, setThreadId] = useState<string | null>(null);

  useEffect(() => {
    const stored = localStorage.getItem(THREAD_KEY);
    if (stored) setThreadId(stored);
  }, []);

  const saveThreadId = (id: string) => {
    localStorage.setItem(THREAD_KEY, id);
    setThreadId(id);
  };

  return { threadId, saveThreadId };
}

When sending the first message:

const { threadId, saveThreadId } = usePersistentThread();

const sendMessage = async (text: string) => {
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ threadId, message: text }),
  });

  const data = await res.json();
  if (!threadId && data.threadId) {
    // new thread created by server
    saveThreadId(data.threadId);
  }

  // data.messages = full or partial message list
  setMessages(data.messages);
};

On mount, if a threadId is present, fetch the existing thread:

useEffect(() => {
  if (!threadId) return;

  (async () => {
    const res = await fetch(`/api/chat/${threadId}`);
    const data = await res.json();
    setMessages(data.messages);
  })();
}, [threadId]);

Server-side logic (pseudo-code)

// POST /api/chat
app.post("/api/chat", async (req, res) => {
  const { threadId, message } = req.body;
  let thread = null;

  if (threadId) {
    thread = await db.thread.findUnique({ where: { id: threadId } });
  }

  if (!thread) {
    thread = await db.thread.create({ data: { userId: null } });
  }

  const userMessage = await db.message.create({
    data: {
      threadId: thread.id,
      role: "user",
      content: message,
    },
  });

  // Call your LLM with the whole thread (or a summary)
  const assistantText = await callLLM(thread.id);

  const assistantMessage = await db.message.create({
    data: {
      threadId: thread.id,
      role: "assistant",
      content: assistantText,
    },
  });

  const messages = await db.message.findMany({
    where: { threadId: thread.id },
    orderBy: { createdAt: "asc" },
  });

  res.json({ threadId: thread.id, messages });
});

With this setup, a refresh will:

  • Read threadId from storage
  • Fetch messages from /api/chat/:threadId
  • Render the full conversation again

Using Assistant UI for persistent chat threads

Assistant UI is an open‑source TypeScript/React library that gives you a production‑ready, ChatGPT-like chat interface. One of its strengths is that it can render the chat interface and store threads in Assistant UI Cloud so sessions persist across refreshes and context builds over time.

When you integrate Assistant UI with Assistant UI Cloud:

  • Threads are stored in the cloud, not just in memory.
  • A page refresh doesn’t reset the conversation; the library rehydrates from the stored thread.
  • Conversations can continue across sessions, and context builds over time.

A high-level flow looks like this:

  1. Initialize Assistant UI with a backend (LangChain, LangGraph, Vercel AI SDK, or your own LLM endpoint).
  2. Enable Assistant UI Cloud (or configure your own storage adapter).
  3. Let Assistant UI manage:
    • Thread creation and IDs
    • Persisting and retrieving messages
    • Streaming and interruptions

Because Assistant UI is React-based and optimized for streaming, it handles both UI and state management. Persisting chat messages becomes a configuration concern instead of custom boilerplate.

If you want to combine server-side persistence with Assistant UI, you typically:

  • Use your backend (LangChain, LangGraph, custom API) to manage threads and messages.
  • Pass the conversation state through Assistant UI’s props/hooks.
  • Optionally rely on Assistant UI Cloud so you don’t have to build your own thread storage logic at all.

Handling streaming responses without losing state

Many chat apps stream tokens from the model. To preserve messages and still support streaming:

  1. Store a stable message ID as soon as streaming starts.
  2. Append tokens to the last assistant message in your UI state.
  3. On completion, persist the full message:
const [messages, setMessages] = useState<Message[]>([]);

async function streamAssistantReply(threadId: string) {
  const response = await fetch(`/api/stream/${threadId}`);
  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let assistantId = crypto.randomUUID();

  // Add placeholder message
  setMessages((prev) => [
    ...prev,
    { id: assistantId, role: "assistant", content: "", createdAt: Date.now() },
  ]);

  let fullText = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const chunk = decoder.decode(value);
    fullText += chunk;

    setMessages((prev) =>
      prev.map((m) =>
        m.id === assistantId ? { ...m, content: fullText } : m
      )
    );
  }

  // Persist final assistant message to DB or Assistant UI Cloud here
}

On a refresh, you fetch the persisted messages and render the complete assistant answers; even in-progress streaming is safe as long as you persist after completion.


Best practices to avoid losing conversations

To ensure users never lose conversation context when they refresh:

  • Always use a thread ID
    Avoid “stateless” requests; every conversation should be tied to an ID.

  • Persist thread ID somewhere stable

    • localStorage for anonymous sessions
    • Server-side session or JWT for authenticated users
    • Optional: include ?threadId=... in the URL for easy sharing
  • Load messages on page initialization
    Make fetching historical messages part of your app’s initial data fetch.

  • Handle empty / corrupted state gracefully

    • If threadId or stored messages are invalid, start a new thread.
    • Don’t crash the UI.
  • Keep message schema simple and consistent

    • id, threadId, role, content, createdAt are usually enough.
    • Add metadata (tool calls, rating, etc.) as optional fields.
  • Leverage a dedicated library where possible If you’re building a ChatGPT-like interface in React, Assistant UI can save you a lot of state-management and persistence work, especially combined with Assistant UI Cloud, LangGraph, or LangChain.


Summary

To persist chat messages so a user can refresh the page and not lose the conversation:

  • Introduce a persistent thread ID.
  • Choose where to store messages:
    • Client-side (localStorage, etc.) for simple, single-device sessions.
    • Server-side (DB, or an LLM framework like LangGraph/LangChain) for real production usage.
    • Or Assistant UI Cloud, which automatically stores threads so sessions persist across refreshes.
  • On page load, rehydrate your chat UI by:
    • Reading the thread ID
    • Fetching messages
    • Populating the UI before sending new messages

With this pattern in place, refreshes, tab closes, and even multi-session usage will feel seamless, and your AI chat will behave much more like a real, persistent assistant.