AI Codex
Developer PathStep 18 of 20
← Prev·Next →
Infrastructure & DeploymentHow It Works

Database-backed conversation history with Supabase and Claude

In brief

In-memory arrays disappear on page reload. How to persist conversation history to Supabase, load it back on session resume, and prune context intelligently.

8 min read·

Contents

Sign in to save

Every Claude integration starts with an in-memory array of messages. This works for demos. It breaks the moment a user refreshes the page, closes a tab, or comes back the next day.

Proper conversation persistence means:

  • Messages survive page reloads and deployments
  • Users can resume where they left off
  • You can inspect conversations for debugging
  • You can build features like conversation search, sharing, or branching

Here is the complete implementation with Supabase.

Schema design

Two tables: conversations (the session) and messages (the turns).

-- Conversations table
create table conversations (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade,
  title text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Messages table
create table messages (
  id uuid primary key default gen_random_uuid(),
  conversation_id uuid references conversations(id) on delete cascade,
  role text not null check (role in ('user', 'assistant')),
  content text not null,
  tokens_used integer,
  created_at timestamptz default now()
);

-- Indexes for fast lookups
create index on conversations(user_id, updated_at desc);
create index on messages(conversation_id, created_at asc);

-- Row-level security
alter table conversations enable row level security;
alter table messages enable row level security;

create policy "Users see own conversations"
  on conversations for all using (auth.uid() = user_id);

create policy "Users see own messages"
  on messages for all
  using (
    exists (
      select 1 from conversations
      where conversations.id = messages.conversation_id
      and conversations.user_id = auth.uid()
    )
  );

Loading a conversation

// lib/conversations.ts
import { createClient } from '@/lib/supabase/server'

export type Message = {
  id: string
  role: 'user' | 'assistant'
  content: string
  created_at: string
}

export async function getConversation(conversationId: string): Promise<Message[]> {
  const supabase = await createClient()

  const { data, error } = await supabase
    .from('messages')
    .select('id, role, content, created_at')
    .eq('conversation_id', conversationId)
    .order('created_at', { ascending: true })

  if (error) {
    console.error('getConversation error:', error)
    return []
  }

  return data ?? []
}

export async function getUserConversations(userId: string) {
  const supabase = await createClient()

  const { data } = await supabase
    .from('conversations')
    .select('id, title, created_at, updated_at')
    .eq('user_id', userId)
    .order('updated_at', { ascending: false })
    .limit(20)

  return data ?? []
}

Saving messages

export async function saveMessage(
  conversationId: string,
  role: 'user' | 'assistant',
  content: string,
  tokensUsed?: number
) {
  const supabase = await createClient()

  const [_, updateResult] = await Promise.all([
    supabase.from('messages').insert({
      conversation_id: conversationId,
      role,
      content,
      tokens_used: tokensUsed ?? null,
    }),
    supabase
      .from('conversations')
      .update({ updated_at: new Date().toISOString() })
      .eq('id', conversationId),
  ])

  return updateResult
}

export async function createConversation(userId: string, firstMessage: string) {
  const supabase = await createClient()

  // Auto-generate title from first message (truncated)
  const title = firstMessage.length > 60
    ? firstMessage.slice(0, 57) + '...'
    : firstMessage

  const { data } = await supabase
    .from('conversations')
    .insert({ user_id: userId, title })
    .select('id')
    .single()

  return data?.id ?? null
}

Wiring it into your API route

// app/api/chat/route.ts
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getConversation, saveMessage, createConversation } from '@/lib/conversations'
import Anthropic from '@anthropic-ai/sdk'

export async function POST(request: Request) {
  const session = await getServerSession(authOptions)
  if (!session?.user?.id) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { userMessage, conversationId: existingId } = await request.json()
  const userId = session.user.id

  // Get or create conversation
  let conversationId = existingId
  if (!conversationId) {
    conversationId = await createConversation(userId, userMessage)
    if (!conversationId) {
      return Response.json({ error: 'Failed to create conversation' }, { status: 500 })
    }
  }

  // Load message history
  const history = await getConversation(conversationId)

  // Build messages array for Claude
  const messages = [
    ...history.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })),
    { role: 'user' as const, content: userMessage },
  ]

  // Save user message before calling Claude
  await saveMessage(conversationId, 'user', userMessage)

  const anthropic = new Anthropic()
  const response = await anthropic.messages.create({
    model: 'claude-opus-4-5',
    max_tokens: 1024,
    messages,
  })

  const assistantContent = response.content[0].type === 'text'
    ? response.content[0].text
    : ''

  // Save assistant response with token count
  const totalTokens = response.usage.input_tokens + response.usage.output_tokens
  await saveMessage(conversationId, 'assistant', assistantContent, totalTokens)

  return Response.json({
    content: assistantContent,
    conversationId,
  })
}

Context window management

Claude has a finite context window. Long conversations eventually exceed it. Prune old messages when the conversation grows:

const MAX_MESSAGES_IN_CONTEXT = 20

function pruneHistory(history: Message[]): Message[] {
  if (history.length <= MAX_MESSAGES_IN_CONTEXT) return history

  // Always keep the first message (often sets important context)
  // Then keep the most recent N-1 messages
  const first = history[0]
  const recent = history.slice(-(MAX_MESSAGES_IN_CONTEXT - 1))
  return [first, ...recent]
}

// Use in route handler:
const prunedHistory = pruneHistory(history)
const messages = [
  ...prunedHistory.map(m => ({ role: m.role, content: m.content })),
  { role: 'user', content: userMessage },
]

A more sophisticated approach counts tokens instead of message count. The usage field returned by Claude tells you how many tokens were used — track this to build a soft limit.

Loading conversation in the UI

// app/chat/[conversationId]/page.tsx
import { getConversation } from '@/lib/conversations'

export default async function ChatPage({ params }: { params: { conversationId: string } }) {
  const messages = await getConversation(params.conversationId)

  return (
    <div>
      {messages.map(msg => (
        <div key={msg.id} className={`message message--${msg.role}`}>
          {msg.content}
        </div>
      ))}
      {/* ChatInput component here */}
    </div>
  )
}

The URL-based approach (/chat/[conversationId]) means users can bookmark, share, or return to any conversation. It is also debuggable — you can look up the conversation ID in Supabase directly.

What not to do

  • Do not store raw HTML in the content column — store markdown and render it on the client
  • Do not lazy-load message history — load it server-side before rendering to avoid layout shift
  • Do not skip the updated_at update — it is how you sort conversations by recency
  • Do not trust the client to pass conversationId without verifying ownership — always check the conversation belongs to the authenticated user

Further reading

  • Messages API reference — current Claude model IDs and context window limits
  • Prompt caching — how to cache conversation history to cut costs on long persistent conversations
  • Token counting — measuring context window usage precisely before you hit limits

Related tools

Next in Developer Path · Step 19 of 20

Continue to the next article in the learning path

Next article →

Weekly brief

For people actually using Claude at work.

Each week: one thing Claude can do in your work that most people haven't figured out yet — plus the failure modes to avoid. No tutorials. No hype.

No spam. Unsubscribe anytime.

What to read next

All articles →