Core Concepts

Persistent Chats

Last updated by
Harlan Wilton
in doc: sync.

The chat endpoint accepts a single message + chat ID. History loads server-side—you control storage, condensing, and context limits.

POST /api/chat
{ message: { role: 'user', content: '...' }, id: 'chat-123' }

Two hooks handle persistence:

  • ai-search:chat:load — Load history before generation
  • ai-search:chat:finish — Save messages after generation

Schema

server/database/schema.ts
import { relations } from 'drizzle-orm'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'

export const chats = sqliteTable('chats', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  title: text('title'),
  userId: text('user_id').notNull(),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})

export const chatsRelations = relations(chats, ({ many }) => ({
  messages: many(messages),
}))

export const messages = sqliteTable('messages', {
  id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
  chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
  role: text('role', { enum: ['user', 'assistant'] }).notNull(),
  parts: text('parts', { mode: 'json' }).$type<unknown[]>(),
  createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})

export const messagesRelations = relations(messages, ({ one }) => ({
  chat: one(chats, {
    fields: [messages.chatId],
    references: [chats.id],
  }),
}))

The parts column stores AI SDK's message format as JSON—text, images, tool calls in one field.

Setup

pnpm add drizzle-orm better-sqlite3
pnpm add -D drizzle-kit @types/better-sqlite3
server/utils/db.ts
import Database from 'better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import * as schema from '../database/schema'

const sqlite = new Database('.data/chat.db')
export const db = drizzle(sqlite, { schema })
drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './server/database/schema.ts',
  out: './server/database/migrations',
  dialect: 'sqlite',
  dbCredentials: { url: '.data/chat.db' },
})
pnpm drizzle-kit generate && pnpm drizzle-kit migrate

Hooks

Load History

Load previous messages when a chat ID is provided:

server/plugins/chat-persistence.ts
import type { ChatLoadContext, ChatLoadResult } from 'nuxt-ai-search'
import { eq } from 'drizzle-orm'
import { defineNitroPlugin } from 'nitropack/runtime'
import { chats, messages } from '../database/schema'
import { db } from '../utils/db'

export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('ai-search:chat:load', async (ctx: ChatLoadContext, result: ChatLoadResult) => {
    if (!ctx.chatId) return

    const userId = ctx.event.context.userId
    if (!userId) return

    const chat = await db.query.chats.findFirst({
      where: eq(chats.id, ctx.chatId),
      with: { messages: { orderBy: (m, { asc }) => [asc(m.createdAt)] } },
    })

    if (chat && chat.userId !== userId) {
      throw createError({ statusCode: 403, message: 'Not your chat' })
    }

    if (chat?.messages.length) {
      result.history = chat.messages.map(m => ({
        role: m.role as 'user' | 'assistant',
        content: m.parts as any, // parts is JSON array of message parts
      }))
    }
  })
})

Save Messages

Save both user and assistant messages after generation:

server/plugins/chat-persistence.ts
import type { ChatFinishContext } from 'nuxt-ai-search'

// Add to same plugin file
nitro.hooks.hook('ai-search:chat:finish', async (ctx: ChatFinishContext) => {
  if (!ctx.chatId) return

  const userId = ctx.event.context.userId
  if (!userId) return

  // Create chat if new
  const existing = await db.query.chats.findFirst({
    where: eq(chats.id, ctx.chatId),
  })

  if (!existing) {
    const firstMsg = ctx.messages.find(m => m.role === 'user')
    const title = typeof firstMsg?.content === 'string'
      ? firstMsg.content.slice(0, 50)
      : 'New Chat'

    await db.insert(chats).values({
      id: ctx.chatId,
      userId,
      title,
    })
  }

  // Save user message
  const userMsg = ctx.messages.at(-2) // second to last
  if (userMsg?.role === 'user') {
    await db.insert(messages).values({
      chatId: ctx.chatId,
      role: 'user',
      parts: typeof userMsg.content === 'string'
        ? [{ type: 'text', text: userMsg.content }]
        : userMsg.content,
    })
  }

  // Save assistant response
  await db.insert(messages).values({
    chatId: ctx.chatId,
    role: 'assistant',
    parts: [{ type: 'text', text: ctx.responseText }],
  })
})

Condensing History

Long conversations burn tokens. Condense old messages in the load hook:

server/plugins/chat-persistence.ts
nitro.hooks.hook('ai-search:chat:load', async (ctx: ChatLoadContext, result: ChatLoadResult) => {
  // ... load messages ...

  if (chat?.messages.length > 20) {
    // Keep last 10 messages, summarize the rest
    const oldMessages = chat.messages.slice(0, -10)
    const recentMessages = chat.messages.slice(-10)

    const summary = await summarizeMessages(oldMessages, ctx.model)

    result.history = [
      { role: 'system', content: `Previous conversation summary: ${summary}` },
      ...recentMessages.map(m => ({
        role: m.role as 'user' | 'assistant',
        content: m.parts,
      })),
    ]
  }
})

Client

Use AI SDK's useChat with prepareSendMessagesRequest to send only the last message:

pages/chat/[id].vue
<script setup lang="ts">
import { useChat } from '@ai-sdk/vue'
import { DefaultChatTransport } from '@ai-sdk/ui-utils'

const route = useRoute()
const chatId = route.params.id as string

const { messages, input, handleSubmit, isLoading } = useChat({
  id: chatId,
  transport: new DefaultChatTransport({
    api: '/api/chat',
    prepareSendMessagesRequest({ messages, id }) {
      return {
        body: {
          message: messages[messages.length - 1],
          id,
        },
      }
    },
  }),
})

// Load existing messages on mount
const { data: chat } = await useFetch(`/api/chats/${chatId}`)
if (chat.value?.messages) {
  messages.value = chat.value.messages.map(m => ({
    id: m.id,
    role: m.role,
    content: typeof m.parts?.[0] === 'object' ? m.parts[0].text : '',
  }))
}
</script>

<template>
  <div>
    <div v-for="m in messages" :key="m.id">
      <strong>{{ m.role }}:</strong> {{ m.content }}
    </div>
    <form @submit="handleSubmit">
      <input v-model="input" :disabled="isLoading">
      <button type="submit" :disabled="isLoading">
        Send
      </button>
    </form>
  </div>
</template>

CRUD Endpoints

List Chats

server/api/chats.get.ts
import { desc, eq } from 'drizzle-orm'
import { chats } from '../database/schema'
import { db } from '../utils/db'

export default defineEventHandler(async (event) => {
  const userId = event.context.userId

  return db.query.chats.findMany({
    where: eq(chats.userId, userId),
    orderBy: [desc(chats.createdAt)],
    columns: { id: true, title: true, createdAt: true },
  })
})

Get Chat

server/api/chats/[chatId].get.ts
import { eq } from 'drizzle-orm'
import { chats } from '../../database/schema'
import { db } from '../../utils/db'

export default defineEventHandler(async (event) => {
  const chatId = event.context.params?.chatId
  const userId = event.context.userId

  const chat = await db.query.chats.findFirst({
    where: eq(chats.id, chatId),
    with: { messages: { orderBy: (m, { asc }) => [asc(m.createdAt)] } },
  })

  if (!chat) throw createError({ statusCode: 404 })
  if (chat.userId !== userId) throw createError({ statusCode: 403 })

  return chat
})

Delete Chat

server/api/chats/[chatId].delete.ts
import { eq } from 'drizzle-orm'
import { chats } from '../../database/schema'
import { db } from '../../utils/db'

export default defineEventHandler(async (event) => {
  const chatId = event.context.params?.chatId
  const userId = event.context.userId

  const chat = await db.query.chats.findFirst({
    where: eq(chats.id, chatId),
  })

  if (!chat) throw createError({ statusCode: 404 })
  if (chat.userId !== userId) throw createError({ statusCode: 403 })

  await db.delete(chats).where(eq(chats.id, chatId))
  return { success: true }
})

Anonymous Users

No auth? Use a visitor cookie:

server/middleware/visitor.ts
import { getCookie, setCookie } from 'h3'

export default defineEventHandler((event) => {
  let visitorId = getCookie(event, 'visitor_id')

  if (!visitorId) {
    visitorId = crypto.randomUUID()
    setCookie(event, 'visitor_id', visitorId, {
      httpOnly: true,
      maxAge: 60 * 60 * 24 * 365,
    })
  }

  event.context.userId = visitorId
})

Cleanup Old Chats

Anonymous chats pile up. Delete after 30 days:

server/tasks/cleanup.ts
import { and, lt, like } from 'drizzle-orm'

export default defineTask({
  meta: { name: 'chat:cleanup' },
  async run() {
    const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)

    await db.delete(chats).where(
      and(
        lt(chats.createdAt, thirtyDaysAgo),
        like(chats.userId, '%-%-%-%-%'),
      ),
    )
  },
})
Did this page help you?